Compare commits
285 Commits
v4.2.1
...
v4.4.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c4b92531f | ||
|
|
46d6159da5 | ||
|
|
aab6e1c52f | ||
|
|
c0a01318b5 | ||
|
|
13090ff6ed | ||
|
|
90a20cd52f | ||
|
|
74245d3f2b | ||
|
|
e48452ccff | ||
|
|
39370ba1ef | ||
|
|
a02cfb4f41 | ||
|
|
4370012b8a | ||
|
|
20c33aea72 | ||
|
|
c11a1aa0e6 | ||
|
|
166b789f3c | ||
|
|
bbc26e272c | ||
|
|
6c839f8075 | ||
|
|
be91f97c83 | ||
|
|
e085cb7350 | ||
|
|
12574bed96 | ||
|
|
2137893111 | ||
|
|
e552a28a4d | ||
|
|
b20031d875 | ||
|
|
558b3fa356 | ||
|
|
cb337cef10 | ||
|
|
cd63f81292 | ||
|
|
6205b03943 | ||
|
|
82924ce8c6 | ||
|
|
b1067c339c | ||
|
|
0d6e4b48f6 | ||
|
|
84d2c408ce | ||
|
|
de434b730e | ||
|
|
1251dbc4b0 | ||
|
|
d115ad228b | ||
|
|
28d6f62b74 | ||
|
|
2b9238144b | ||
|
|
a2e1b7ae0a | ||
|
|
a00bb6d5ef | ||
|
|
da12b651e4 | ||
|
|
a936c14cf2 | ||
|
|
e6aad24e5f | ||
|
|
8dac96f415 | ||
|
|
aae0802ec8 | ||
|
|
74a20ef70c | ||
|
|
a2a29628a7 | ||
|
|
0b046315ac | ||
|
|
372d7ffaa9 | ||
|
|
ece928d57e | ||
|
|
e1740a8be4 | ||
|
|
36eba01ef4 | ||
|
|
9e045a44db | ||
|
|
b7d42ef889 | ||
|
|
e55cd82ec3 | ||
|
|
34e08b2058 | ||
|
|
5e9b7366cc | ||
|
|
64fb1f0b85 | ||
|
|
84e43c57f6 | ||
|
|
ffa37b1684 | ||
|
|
86271fe109 | ||
|
|
5dab099b5c | ||
|
|
199e61e5c6 | ||
|
|
76b4fd34c1 | ||
|
|
b159496257 | ||
|
|
c0fb29d5f7 | ||
|
|
4ab7e5e1c8 | ||
|
|
9e847c2e1f | ||
|
|
d105e59655 | ||
|
|
f128ebac87 | ||
|
|
b4de9c266b | ||
|
|
e410fc7736 | ||
|
|
f5bd6eb4c3 | ||
|
|
cee403c1ed | ||
|
|
8786e2147a | ||
|
|
6b8705e9f4 | ||
|
|
acfb98bd81 | ||
|
|
c240456b80 | ||
|
|
9e1641a154 | ||
|
|
17cd3cdca4 | ||
|
|
aa264690ab | ||
|
|
0e837ae392 | ||
|
|
68525b4131 | ||
|
|
8775db7c97 | ||
|
|
f266debd56 | ||
|
|
044c7a8191 | ||
|
|
677c6aeb2e | ||
|
|
7b8f5be821 | ||
|
|
21765905a7 | ||
|
|
70a9c01477 | ||
|
|
678dbec3e2 | ||
|
|
bd4c7dffbf | ||
|
|
1eb4030080 | ||
|
|
bcc64442e9 | ||
|
|
1246b9e376 | ||
|
|
d6e9a87d58 | ||
|
|
cddd3cd673 | ||
|
|
e00ef7c7da | ||
|
|
1a201cfadf | ||
|
|
a4ecb41743 | ||
|
|
e347f5329c | ||
|
|
741b3e8848 | ||
|
|
7b4710d198 | ||
|
|
c77f6100c5 | ||
|
|
138dc7fe8f | ||
|
|
0535d84719 | ||
|
|
f4447ffe89 | ||
|
|
146d5f99bc | ||
|
|
73e5fff8f5 | ||
|
|
33e7538172 | ||
|
|
49c9c501aa | ||
|
|
a177dc5a13 | ||
|
|
a4f207875f | ||
|
|
bcca0998d5 | ||
|
|
95cc9e1e55 | ||
|
|
ab5ae2d2cb | ||
|
|
40a91a7273 | ||
|
|
1240b31df8 | ||
|
|
ff14730738 | ||
|
|
0cba3a4bb3 | ||
|
|
208f8a3346 | ||
|
|
caa3efb9ed | ||
|
|
4beb916754 | ||
|
|
c1ee04eed1 | ||
|
|
0ad3bc9993 | ||
|
|
9893aa665a | ||
|
|
fda8836ab8 | ||
|
|
05fdd07409 | ||
|
|
fb8f5facd0 | ||
|
|
8bdb784a14 | ||
|
|
5d785aad2e | ||
|
|
ce11d58a76 | ||
|
|
887b374bfc | ||
|
|
94dc967197 | ||
|
|
4319a581ca | ||
|
|
fb05218558 | ||
|
|
edf2d02a0d | ||
|
|
87ba429dfd | ||
|
|
7af1a7cf76 | ||
|
|
df619acdd4 | ||
|
|
503d0cd451 | ||
|
|
1294a00ee7 | ||
|
|
0a8069ada2 | ||
|
|
c167f594b9 | ||
|
|
ce5124594d | ||
|
|
5de96aa7c4 | ||
|
|
cdbf8e2715 | ||
|
|
69b2f8f535 | ||
|
|
ff17f89551 | ||
|
|
358712fa31 | ||
|
|
75cad8a6bf | ||
|
|
e5969e197a | ||
|
|
a9ffe4e039 | ||
|
|
bf2129c4ae | ||
|
|
04f10aede7 | ||
|
|
ffbff93374 | ||
|
|
f3630d934c | ||
|
|
6f59df4e33 | ||
|
|
e44e9a0940 | ||
|
|
c43ba7bf23 | ||
|
|
9e48e1f74a | ||
|
|
0001885971 | ||
|
|
e0c932c04e | ||
|
|
01a86336c0 | ||
|
|
48d24d3582 | ||
|
|
07ef6e4090 | ||
|
|
19163776e3 | ||
|
|
62b1f786f1 | ||
|
|
d479e9b2bf | ||
|
|
0beb260b00 | ||
|
|
bc28568c63 | ||
|
|
a4dd520248 | ||
|
|
9ad29aa17e | ||
|
|
6f24281671 | ||
|
|
eb81b62892 | ||
|
|
ef1133ee98 | ||
|
|
1872744543 | ||
|
|
303aeaaba5 | ||
|
|
c5359d698c | ||
|
|
acd72fb269 | ||
|
|
8d096f469d | ||
|
|
b204d21d9e | ||
|
|
c9620a594e | ||
|
|
538024de61 | ||
|
|
5c9a14fdb6 | ||
|
|
9295bdca3e | ||
|
|
00466f4654 | ||
|
|
5d976d3c67 | ||
|
|
7360bd098a | ||
|
|
3a755286c1 | ||
|
|
e5f07993a7 | ||
|
|
56a3f98dc0 | ||
|
|
9949ac073f | ||
|
|
c102a884d1 | ||
|
|
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 |
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 /
|
||||
|
||||
@@ -8,7 +8,7 @@ If you believe that some of the clauses in this document are not aligned with ou
|
||||
|
||||
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.html).
|
||||
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
|
||||
|
||||
|
||||
56
README.md
56
README.md
@@ -5,8 +5,9 @@
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
[](https://mastodon.social/@simplex)
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Join a user group](#join-a-user-group)
|
||||
- [Contribute](#contribute)
|
||||
- [Help us with donations](#help-us-with-donations)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
@@ -84,16 +86,14 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration.](./blog/20221206-simplex-chat-v4.3-voice-messages.md)
|
||||
|
||||
[Nov 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)
|
||||
|
||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
|
||||
|
||||
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
|
||||
|
||||
[All updates](./blog)
|
||||
|
||||
## Make a private connection
|
||||
@@ -102,7 +102,7 @@ You need to share a link or scan a QR code (in person or during a video call) to
|
||||
|
||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||
|
||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
|
||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
||||
|
||||
## :zap: Quick installation of a terminal app
|
||||
|
||||
@@ -186,20 +186,42 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Chat database encryption.
|
||||
- ✅ Automatic chat history deletion.
|
||||
- ✅ Links to join groups and improve groups stability.
|
||||
- 🏗 SMP queue redundancy and rotation.
|
||||
- 🏗 Voice messages.
|
||||
- Feeds/broadcasts.
|
||||
- Disappearing messages, with mutual agreement.
|
||||
- ✅ 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.
|
||||
- ✅ Advanced server configuration.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Contact verification via a separate out-of-band channel.
|
||||
- 🏗 Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- Access password/pin (with optional alternative access password).
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Join a user group
|
||||
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
@@ -224,8 +246,10 @@ It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- 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,
|
||||
@@ -238,7 +262,7 @@ SimpleX Chat founder
|
||||
|
||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
||||
|
||||
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).
|
||||
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.
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 68
|
||||
versionName "4.2.1"
|
||||
versionCode 82
|
||||
versionName "4.4-beta.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<application
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupOnly="true"
|
||||
android:backupAgent="BackupAgent"
|
||||
android:icon="@mipmap/icon"
|
||||
android:label="${app_name}"
|
||||
android:extractNativeLibs="${extract_native_libs}"
|
||||
@@ -102,7 +104,9 @@
|
||||
|
||||
|
||||
<activity android:name=".views.call.IncomingCallActivity"
|
||||
android:showOnLockScreen="true"/>
|
||||
android:showOnLockScreen="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"/>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.backup.BackupAgentHelper
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.content.Context
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||
|
||||
class BackupAgent: BackupAgentHelper() {
|
||||
override fun onFullBackup(data: FullBackupDataOutput?) {
|
||||
if (applicationContext
|
||||
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
|
||||
) {
|
||||
super.onFullBackup(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@ package chat.simplex.app
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
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
|
||||
@@ -19,7 +22,9 @@ 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
|
||||
@@ -36,6 +41,8 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
@@ -67,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(
|
||||
@@ -119,7 +133,15 @@ class MainActivity: FragmentActivity() {
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
if (
|
||||
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|
||||
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|
||||
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
|
||||
) {
|
||||
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
||||
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
||||
clearAuthState()
|
||||
@@ -317,19 +339,62 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||
currentChatId?.let {
|
||||
ChatView(it, chatModel, onComposed)
|
||||
}
|
||||
}
|
||||
}
|
||||
else ChatView(chatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
||||
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
@@ -413,7 +478,6 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
// TODO open from chat list view
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { linkType ->
|
||||
|
||||
@@ -11,8 +11,7 @@ import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -32,10 +31,13 @@ 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
|
||||
|
||||
var isAppOnForeground: Boolean = false
|
||||
|
||||
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
|
||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||
@@ -95,6 +97,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
isAppOnForeground = true
|
||||
if (chatModel.chatRunning.value == true) {
|
||||
kotlin.runCatching {
|
||||
val chats = chatController.apiGetChats()
|
||||
@@ -103,6 +106,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
isAppOnForeground = true
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
@@ -111,10 +115,14 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
|
||||
* It can happen when app was started and a user enables battery optimization while app in background
|
||||
* */
|
||||
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||
if (chatModel.chatRunning.value != false &&
|
||||
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
|
||||
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
|
||||
) {
|
||||
SimplexService.start(applicationContext)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
else -> isAppOnForeground = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +176,18 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
val server = LocalServerSocket(socketName)
|
||||
var server: LocalServerSocket? = null
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
server = LocalServerSocket(socketName + i)
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
if (server == null) {
|
||||
throw Error("Unable to setup local server socket. Contact developers")
|
||||
}
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
val receiver = server.accept()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -2,8 +2,13 @@ package chat.simplex.app.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
@@ -20,7 +25,13 @@ import kotlinx.serialization.descriptors.*
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import kotlin.time.*
|
||||
|
||||
/*
|
||||
* 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 +50,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 +84,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) {
|
||||
@@ -90,7 +107,7 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
|
||||
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact && !contact.viaGroupLink)
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
|
||||
|
||||
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
|
||||
|
||||
@@ -204,6 +221,9 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
|
||||
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (cItem.isRcvNew) {
|
||||
decreaseCounterInChat(cInfo.id)
|
||||
}
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
@@ -211,13 +231,14 @@ class ChatModel(val controller: ChatController) {
|
||||
chat = chats[i]
|
||||
val pItem = chat.chatItems.lastOrNull()
|
||||
if (pItem?.id == cItem.id) {
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
|
||||
}
|
||||
}
|
||||
// remove from current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
AudioPlayer.stop(chatItems[itemIndex])
|
||||
chatItems.removeAt(itemIndex)
|
||||
}
|
||||
}
|
||||
@@ -261,7 +282,13 @@ class ChatModel(val controller: ChatController) {
|
||||
while (i < chatItems.count()) {
|
||||
val item = chatItems[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
|
||||
chatItems[i] = item.withStatus(CIStatus.RcvRead())
|
||||
val newItem = item.withStatus(CIStatus.RcvRead())
|
||||
chatItems[i] = newItem
|
||||
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
|
||||
chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
|
||||
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
|
||||
)
|
||||
}
|
||||
markedRead++
|
||||
}
|
||||
i += 1
|
||||
@@ -341,6 +368,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
|
||||
@@ -354,6 +382,7 @@ data class User(
|
||||
userContactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = LocalProfile.sampleData,
|
||||
fullPreferences = FullChatPreferences.sampleData,
|
||||
activeUser = true
|
||||
)
|
||||
}
|
||||
@@ -378,11 +407,14 @@ interface SomeChat {
|
||||
val ready: Boolean
|
||||
val sendMsgEnabled: Boolean
|
||||
val ntfsEnabled: Boolean
|
||||
val incognito: Boolean
|
||||
fun featureEnabled(feature: ChatFeature): Boolean
|
||||
val timedMessagesTTL: Int?
|
||||
val createdAt: Instant
|
||||
val updatedAt: Instant
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Serializable @Stable
|
||||
data class Chat (
|
||||
val chatInfo: ChatInfo,
|
||||
val chatItems: List<ChatItem>,
|
||||
@@ -428,7 +460,6 @@ data class Chat (
|
||||
|
||||
@Serializable
|
||||
sealed class ChatInfo: SomeChat, NamedChat {
|
||||
abstract val incognito: Boolean
|
||||
|
||||
@Serializable @SerialName("direct")
|
||||
data class Direct(val contact: Contact): ChatInfo() {
|
||||
@@ -438,8 +469,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = contact.apiId
|
||||
override val ready get() = contact.ready
|
||||
override val sendMsgEnabled get() = contact.sendMsgEnabled
|
||||
override val ntfsEnabled get() = contact.chatSettings.enableNtfs
|
||||
override val incognito get() = contact.contactConnIncognito
|
||||
override val ntfsEnabled get() = contact.ntfsEnabled
|
||||
override val incognito get() = contact.incognito
|
||||
override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL
|
||||
override val createdAt get() = contact.createdAt
|
||||
override val updatedAt get() = contact.updatedAt
|
||||
override val displayName get() = contact.displayName
|
||||
@@ -460,8 +493,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = groupInfo.apiId
|
||||
override val ready get() = groupInfo.ready
|
||||
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
|
||||
override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs
|
||||
override val incognito get() = groupInfo.membership.memberIncognito
|
||||
override val ntfsEnabled get() = groupInfo.ntfsEnabled
|
||||
override val incognito get() = groupInfo.incognito
|
||||
override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL
|
||||
override val createdAt get() = groupInfo.createdAt
|
||||
override val updatedAt get() = groupInfo.updatedAt
|
||||
override val displayName get() = groupInfo.displayName
|
||||
@@ -482,8 +517,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = contactRequest.apiId
|
||||
override val ready get() = contactRequest.ready
|
||||
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = false
|
||||
override val ntfsEnabled get() = contactRequest.ntfsEnabled
|
||||
override val incognito get() = contactRequest.incognito
|
||||
override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL
|
||||
override val createdAt get() = contactRequest.createdAt
|
||||
override val updatedAt get() = contactRequest.updatedAt
|
||||
override val displayName get() = contactRequest.displayName
|
||||
@@ -504,8 +541,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = contactConnection.apiId
|
||||
override val ready get() = contactConnection.ready
|
||||
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
|
||||
override val ntfsEnabled get() = false
|
||||
override val ntfsEnabled get() = contactConnection.incognito
|
||||
override val incognito get() = contactConnection.incognito
|
||||
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
|
||||
override val createdAt get() = contactConnection.createdAt
|
||||
override val updatedAt get() = contactConnection.updatedAt
|
||||
override val displayName get() = contactConnection.displayName
|
||||
@@ -527,9 +566,10 @@ data class Contact(
|
||||
val profile: LocalProfile,
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
val contactUsed: Boolean,
|
||||
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 {
|
||||
@@ -539,28 +579,47 @@ data class Contact(
|
||||
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
||||
override val sendMsgEnabled get() = true
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val incognito get() = contactConnIncognito
|
||||
override fun featureEnabled(feature: ChatFeature) = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
|
||||
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
|
||||
}
|
||||
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
|
||||
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
override val localAlias get() = profile.localAlias
|
||||
val verified get() = activeConn.connectionCode != null
|
||||
|
||||
val isIndirectContact: Boolean get() =
|
||||
activeConn.connLevel > 0 || viaGroup != null
|
||||
|
||||
val viaGroupLink: Boolean get() =
|
||||
activeConn.viaGroupLink
|
||||
val directOrUsed: Boolean get() =
|
||||
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
|
||||
|
||||
val contactConnIncognito =
|
||||
activeConn.customUserProfileId != null
|
||||
|
||||
fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
|
||||
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
|
||||
}
|
||||
|
||||
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
|
||||
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sampleData = Contact(
|
||||
contactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
contactUsed = true,
|
||||
chatSettings = ChatSettings(true),
|
||||
userPreferences = ChatPreferences(),
|
||||
userPreferences = ChatPreferences.sampleData,
|
||||
mergedPreferences = ContactUserPreferences.sampleData,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -582,7 +641,14 @@ class ContactSubStatus(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null) {
|
||||
data class Connection(
|
||||
val connId: Long,
|
||||
val connStatus: ConnStatus,
|
||||
val connLevel: Int,
|
||||
val viaGroupLink: Boolean,
|
||||
val customUserProfileId: Long? = null,
|
||||
val connectionCode: SecurityCode? = null
|
||||
) {
|
||||
val id: ChatId get() = ":$connId"
|
||||
companion object {
|
||||
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
|
||||
@@ -590,12 +656,14 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Profile(
|
||||
data class SecurityCode(val securityCode: String, val verifiedAt: Instant)
|
||||
|
||||
@Serializable
|
||||
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
|
||||
@@ -603,7 +671,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(
|
||||
@@ -620,18 +688,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 = ""
|
||||
)
|
||||
}
|
||||
@@ -648,10 +716,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 {
|
||||
@@ -661,6 +729,13 @@ data class GroupInfo (
|
||||
override val ready get() = membership.memberActive
|
||||
override val sendMsgEnabled get() = membership.memberActive
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val incognito get() = membership.memberIncognito
|
||||
override fun featureEnabled(feature: ChatFeature) = when (feature) {
|
||||
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
|
||||
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
|
||||
ChatFeature.Voice -> fullGroupPreferences.voice.on
|
||||
}
|
||||
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
|
||||
override val displayName get() = groupProfile.displayName
|
||||
override val fullName get() = groupProfile.fullName
|
||||
override val image get() = groupProfile.image
|
||||
@@ -680,6 +755,7 @@ data class GroupInfo (
|
||||
groupId = 1,
|
||||
localDisplayName = "team",
|
||||
groupProfile = GroupProfile.sampleData,
|
||||
fullGroupPreferences = FullGroupPreferences.sampleData,
|
||||
membership = GroupMember.sampleData,
|
||||
hostConnCustomUserProfileId = null,
|
||||
chatSettings = ChatSettings(true),
|
||||
@@ -690,11 +766,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(
|
||||
@@ -723,6 +800,7 @@ data class GroupMember (
|
||||
val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
|
||||
val fullName: String get() = memberProfile.fullName
|
||||
val image: String? get() = memberProfile.image
|
||||
val verified get() = activeConn?.connectionCode != null
|
||||
|
||||
val chatViewName: String
|
||||
get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
|
||||
@@ -902,6 +980,9 @@ class UserContactRequest (
|
||||
override val ready get() = true
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = false
|
||||
override fun featureEnabled(feature: ChatFeature) = false
|
||||
override val timedMessagesTTL: Int? get() = null
|
||||
override val displayName get() = profile.displayName
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
@@ -937,6 +1018,9 @@ class PendingContactConnection(
|
||||
override val ready get() = false
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = customUserProfileId != null
|
||||
override fun featureEnabled(feature: ChatFeature) = false
|
||||
override val timedMessagesTTL: Int? get() = null
|
||||
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
|
||||
override val displayName: String get() {
|
||||
if (localAlias.isNotEmpty()) return localAlias
|
||||
@@ -956,8 +1040,6 @@ class PendingContactConnection(
|
||||
|
||||
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
|
||||
|
||||
val incognito = customUserProfileId != null
|
||||
|
||||
val description: String get() {
|
||||
val initiated = pccConnStatus.initiated
|
||||
return if (initiated == null) "" else generalGetString(
|
||||
@@ -1015,7 +1097,7 @@ class AChatItem (
|
||||
val chatItem: ChatItem
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Serializable @Stable
|
||||
data class ChatItem (
|
||||
val chatDir: CIDirection,
|
||||
val meta: CIMeta,
|
||||
@@ -1027,13 +1109,16 @@ data class ChatItem (
|
||||
val id: Long get() = meta.itemId
|
||||
val timestampText: String get() = meta.timestampText
|
||||
|
||||
val text: String get() =
|
||||
when {
|
||||
val text: String get() {
|
||||
val mc = content.msgContent
|
||||
return when {
|
||||
content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(R.string.voice_message_with_duration), durationText(mc.duration))
|
||||
content.text == "" && file != null -> file.fileName
|
||||
else -> content.text
|
||||
}
|
||||
}
|
||||
|
||||
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
|
||||
val isRcvNew: Boolean get() = meta.isRcvNew
|
||||
|
||||
val memberDisplayName: String? get() =
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
|
||||
@@ -1046,30 +1131,42 @@ data class ChatItem (
|
||||
else -> false
|
||||
}
|
||||
|
||||
val isCall: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.SndCall -> true
|
||||
is CIContent.RcvCall -> true
|
||||
else -> false
|
||||
}
|
||||
private val showNtfDir: Boolean get() = !chatDir.sent
|
||||
|
||||
val isMutedMemberEvent: Boolean get() =
|
||||
val showNotification: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.RcvGroupEventContent ->
|
||||
when (content.rcvGroupEvent) {
|
||||
is RcvGroupEvent.GroupUpdated -> true
|
||||
is RcvGroupEvent.MemberConnected -> true
|
||||
is RcvGroupEvent.UserDeleted -> false
|
||||
is RcvGroupEvent.GroupDeleted -> false
|
||||
is RcvGroupEvent.MemberAdded -> false
|
||||
is RcvGroupEvent.MemberLeft -> false
|
||||
is RcvGroupEvent.MemberRole -> true
|
||||
is RcvGroupEvent.UserRole -> false
|
||||
is RcvGroupEvent.MemberDeleted -> false
|
||||
is RcvGroupEvent.InvitedViaGroupLink -> false
|
||||
}
|
||||
is CIContent.SndGroupEventContent -> true
|
||||
else -> false
|
||||
is CIContent.SndMsgContent -> showNtfDir
|
||||
is CIContent.RcvMsgContent -> showNtfDir
|
||||
is CIContent.SndDeleted -> showNtfDir
|
||||
is CIContent.RcvDeleted -> showNtfDir
|
||||
is CIContent.SndCall -> showNtfDir
|
||||
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
|
||||
is CIContent.RcvIntegrityError -> showNtfDir
|
||||
is CIContent.RcvGroupInvitation -> showNtfDir
|
||||
is CIContent.SndGroupInvitation -> showNtfDir
|
||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||
is RcvGroupEvent.MemberAdded -> false
|
||||
is RcvGroupEvent.MemberConnected -> false
|
||||
is RcvGroupEvent.MemberLeft -> false
|
||||
is RcvGroupEvent.MemberRole -> false
|
||||
is RcvGroupEvent.UserRole -> showNtfDir
|
||||
is RcvGroupEvent.MemberDeleted -> false
|
||||
is RcvGroupEvent.UserDeleted -> showNtfDir
|
||||
is RcvGroupEvent.GroupDeleted -> showNtfDir
|
||||
is RcvGroupEvent.GroupUpdated -> false
|
||||
is RcvGroupEvent.InvitedViaGroupLink -> false
|
||||
}
|
||||
is CIContent.SndGroupEventContent -> showNtfDir
|
||||
is CIContent.RcvConnEventContent -> false
|
||||
is CIContent.SndConnEventContent -> showNtfDir
|
||||
is CIContent.RcvChatFeature -> false
|
||||
is CIContent.SndChatFeature -> showNtfDir
|
||||
is CIContent.RcvChatPreference -> false
|
||||
is CIContent.SndChatPreference -> showNtfDir
|
||||
is CIContent.RcvGroupFeature -> false
|
||||
is CIContent.SndGroupFeature -> showNtfDir
|
||||
is CIContent.RcvChatFeatureRejected -> showNtfDir
|
||||
is CIContent.RcvGroupFeatureRejected -> showNtfDir
|
||||
}
|
||||
|
||||
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
|
||||
@@ -1085,11 +1182,12 @@ data class ChatItem (
|
||||
file: CIFile? = null,
|
||||
itemDeleted: Boolean = false,
|
||||
itemEdited: Boolean = false,
|
||||
itemTimed: CITimed? = null,
|
||||
editable: Boolean = true
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, null, editable),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
|
||||
quotedItem = quotedItem,
|
||||
file = file
|
||||
@@ -1142,6 +1240,40 @@ data class ChatItem (
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
|
||||
fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
|
||||
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
|
||||
|
||||
val deletedItemDummy: ChatItem
|
||||
get() = ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta(
|
||||
itemId = TEMP_DELETED_CHAT_ITEM_ID,
|
||||
itemTs = Clock.System.now(),
|
||||
itemText = generalGetString(R.string.deleted_description),
|
||||
itemStatus = CIStatus.RcvRead(),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
itemDeleted = false,
|
||||
itemEdited = false,
|
||||
itemTimed = null,
|
||||
itemLive = false,
|
||||
editable = false
|
||||
),
|
||||
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,16 +1299,33 @@ data class CIMeta (
|
||||
val itemText: String,
|
||||
val itemStatus: CIStatus,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val itemDeleted: Boolean,
|
||||
val itemEdited: Boolean,
|
||||
val itemTimed: CITimed?,
|
||||
val itemLive: Boolean?,
|
||||
val editable: Boolean
|
||||
) {
|
||||
val timestampText: String get() = getTimestampText(itemTs)
|
||||
val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now()
|
||||
val isLive: Boolean get() = itemLive == true
|
||||
val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null
|
||||
|
||||
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
|
||||
|
||||
fun statusIcon(primaryColor: Color, metaColor: Color = HighOrLowlight): Pair<ImageVector, Color>? =
|
||||
when (itemStatus) {
|
||||
is CIStatus.SndSent -> Icons.Filled.Check to metaColor
|
||||
is CIStatus.SndErrorAuth -> Icons.Filled.Close to Color.Red
|
||||
is CIStatus.SndError -> Icons.Filled.WarningAmber to WarningYellow
|
||||
is CIStatus.RcvNew -> Icons.Filled.Circle to primaryColor
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSample(
|
||||
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
|
||||
itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true
|
||||
itemDeleted: Boolean = false, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
|
||||
): CIMeta =
|
||||
CIMeta(
|
||||
itemId = id,
|
||||
@@ -1184,13 +1333,22 @@ data class CIMeta (
|
||||
itemText = text,
|
||||
itemStatus = status,
|
||||
createdAt = ts,
|
||||
updatedAt = ts,
|
||||
itemDeleted = itemDeleted,
|
||||
itemEdited = itemEdited,
|
||||
itemTimed = itemTimed,
|
||||
itemLive = itemLive,
|
||||
editable = editable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CITimed(
|
||||
val ttl: Int,
|
||||
val deleteAt: Instant?
|
||||
)
|
||||
|
||||
fun getTimestampText(t: Instant): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
|
||||
@@ -1206,7 +1364,7 @@ sealed class CIStatus {
|
||||
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
|
||||
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
|
||||
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
|
||||
@Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus()
|
||||
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
|
||||
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
|
||||
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
|
||||
}
|
||||
@@ -1238,21 +1396,55 @@ 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: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): 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 -> featureText(feature, enabled.text, param)
|
||||
is SndChatFeature -> featureText(feature, enabled.text, param)
|
||||
is RcvChatPreference -> preferenceText(feature, allowed, param)
|
||||
is SndChatPreference -> preferenceText(feature, allowed, param)
|
||||
is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param)
|
||||
is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param)
|
||||
is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun featureText(feature: Feature, enabled: String, param: Int?): String =
|
||||
if (feature.hasParam) {
|
||||
"${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
|
||||
} else {
|
||||
"${feature.text}: $enabled"
|
||||
}
|
||||
|
||||
fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
|
||||
allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
|
||||
"offered ${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
|
||||
allowed != FeatureAllowed.NO ->
|
||||
"offered ${feature.text}"
|
||||
else ->
|
||||
"cancelled ${feature.text}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1265,7 +1457,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.text == "" && content is MsgContent.MCVoice)
|
||||
durationText(content.duration)
|
||||
else
|
||||
content.text
|
||||
}
|
||||
|
||||
|
||||
fun sender(membership: GroupMember?): String? = when (chatDir) {
|
||||
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
|
||||
@@ -1334,16 +1532,12 @@ 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()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is MCText -> "text $text"
|
||||
is MCLink -> "json ${json.encodeToString(this)}"
|
||||
is MCImage -> "json ${json.encodeToString(this)}"
|
||||
is MCFile -> "json ${json.encodeToString(this)}"
|
||||
is MCUnknown -> "json $json"
|
||||
}
|
||||
val cmdString: String get() =
|
||||
if (this is MCUnknown) "json $json" else "json ${json.encodeToString(this)}"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -1414,6 +1608,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)
|
||||
}
|
||||
@@ -1445,6 +1643,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")
|
||||
@@ -1458,12 +1662,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
|
||||
@@ -1475,6 +1688,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()
|
||||
|
||||
@@ -1486,6 +1700,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
|
||||
}
|
||||
@@ -1495,6 +1710,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"),
|
||||
@@ -1510,7 +1738,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
|
||||
@@ -1545,13 +1773,13 @@ 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), durationText(sec))
|
||||
Error -> generalGetString(R.string.callstatus_error)
|
||||
}
|
||||
|
||||
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||
}
|
||||
|
||||
fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||
|
||||
@Serializable
|
||||
sealed class MsgErrorType() {
|
||||
@Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()
|
||||
|
||||
@@ -3,9 +3,11 @@ package chat.simplex.app.model
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.graphics.BitmapFactory
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import chat.simplex.app.*
|
||||
@@ -23,9 +25,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||
|
||||
// DO NOT change notification channel settings / names
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
|
||||
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
@@ -37,24 +39,29 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
|
||||
init {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel())
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||
// Remove old channels since they can't be edited
|
||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||
}
|
||||
|
||||
enum class NotificationAction {
|
||||
ACCEPT_CONTACT_REQUEST
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(): NotificationChannel {
|
||||
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
|
||||
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||
callChannel.setSound(soundUri, attrs)
|
||||
callChannel.enableVibration(true)
|
||||
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
|
||||
// (wait, vibration duration, wait till off, wait till on again = ringtone mp3 duration - vibration duration - ~50ms lost somewhere)
|
||||
callChannel.vibrationPattern = longArrayOf(250, 250, 0, 2600)
|
||||
return callChannel
|
||||
}
|
||||
|
||||
@@ -151,24 +158,34 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
|
||||
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
||||
if (isAppOnForeground(context)) return
|
||||
val keyguardManager = getKeyguardManager(context)
|
||||
Log.d(TAG,
|
||||
"notifyCallInvitation pre-requests: " +
|
||||
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
||||
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
||||
"onForeground ${SimplexApp.context.isAppOnForeground}"
|
||||
)
|
||||
if (SimplexApp.context.isAppOnForeground) return
|
||||
val contactId = invitation.contact.id
|
||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||
val keyguardManager = getKeyguardManager(context)
|
||||
val image = invitation.contact.image
|
||||
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
|
||||
var ntfBuilder =
|
||||
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, LockScreenCallChannel)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSilent(true)
|
||||
} else {
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, contactId, true))
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setSound(soundUri)
|
||||
}
|
||||
val text = generalGetString(
|
||||
@@ -197,8 +214,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
.setLargeIcon(largeIcon)
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
val notification = ntfBuilder.build()
|
||||
// This makes notification sound and vibration repeat endlessly
|
||||
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
notify(CallNotificationId, ntfBuilder.build())
|
||||
notify(CallNotificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,33 +226,35 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
manager.cancel(CallNotificationId)
|
||||
}
|
||||
|
||||
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
val md = cItem.formattedText
|
||||
return if (md == null) {
|
||||
if (cItem.content.text != "") {
|
||||
cItem.content.text
|
||||
} else {
|
||||
cItem.file?.fileName ?: ""
|
||||
}
|
||||
} else {
|
||||
return if (md != null) {
|
||||
var res = ""
|
||||
for (ft in md) {
|
||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||
}
|
||||
res
|
||||
} else {
|
||||
cItem.text
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
var intent = Intent(context, MainActivity::class.java)
|
||||
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.setAction(intentAction)
|
||||
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
return if (!broadcast) {
|
||||
TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
} else {
|
||||
PendingIntent.getBroadcast(SimplexApp.context, uniqueInt, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +272,12 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
||||
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||
}
|
||||
RejectCallAction -> {
|
||||
val invitation = SimplexApp.context.chatModel.callInvitations[chatId]
|
||||
if (invitation != null) {
|
||||
SimplexApp.context.chatModel.callManager.endCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -135,7 +135,21 @@ fun TerminalLayout(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
||||
SendMsgView(
|
||||
composeState = composeState,
|
||||
showVoiceRecordIcon = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = false,
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = sendCommand,
|
||||
sendLiveMessage = null,
|
||||
updateLiveMessage = null,
|
||||
::onMessageChange,
|
||||
textStyle
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
|
||||
@@ -111,9 +111,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
||||
Profile(displayName, fullName, null)
|
||||
)
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
SimplexService.start(chatModel.controller.appContext)
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -43,8 +44,7 @@ class IncomingCallActivity: ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val activity = this
|
||||
setContent { IncomingCallActivityView(vm.chatModel, activity) }
|
||||
setContent { IncomingCallActivityView(vm.chatModel) }
|
||||
unlockForIncomingCall()
|
||||
}
|
||||
|
||||
@@ -83,11 +83,12 @@ fun getKeyguardManager(context: Context): KeyguardManager =
|
||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
@Composable
|
||||
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
||||
fun IncomingCallActivityView(m: ChatModel) {
|
||||
val switchingCall = m.switchingCall.value
|
||||
val invitation = m.activeCallInvitation.value
|
||||
val call = m.activeCall.value
|
||||
val showCallView = m.showCallView.value
|
||||
val activity = LocalContext.current as Activity
|
||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||
@@ -105,36 +106,41 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} else if (invitation != null) {
|
||||
IncomingCallLockScreenAlert(invitation, m, activity)
|
||||
IncomingCallLockScreenAlert(invitation, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
|
||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
val cm = chatModel.callManager
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation,
|
||||
callOnLockScreen,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
SoundPlayer.shared.stop()
|
||||
var intent = Intent(activity, MainActivity::class.java)
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(OpenChatAction)
|
||||
.putExtra("chatId", invitation.contact.id)
|
||||
activity.startActivity(intent)
|
||||
activity.finish()
|
||||
context.startActivity(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
|
||||
getKeyguardManager(context).requestDismissKeyguard((context as Activity), null)
|
||||
}
|
||||
(context as Activity).finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
IncomingCallAlertLayout(
|
||||
invitation,
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ 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.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(
|
||||
@@ -46,6 +47,7 @@ fun ChatInfoView(
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
@@ -58,14 +60,48 @@ fun ChatInfoView(
|
||||
connStats,
|
||||
customUserProfile,
|
||||
localAlias,
|
||||
connectionCode,
|
||||
developerTools,
|
||||
onLocalAliasChanged = {
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
ContactPreferencesView(chatModel, user, contact.contactId, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
switchContactAddress = {
|
||||
showSwitchContactAddressAlert(chatModel, contact.contactId)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||
VerifyCodeView(
|
||||
ct.displayName,
|
||||
connectionCode,
|
||||
ct.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.updateContact(
|
||||
ct.copy(
|
||||
activeConn = ct.activeConn.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -115,11 +151,14 @@ fun ChatInfoLayout(
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
switchContactAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -144,12 +183,19 @@ fun ChatInfoLayout(
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
if (developerTools) {
|
||||
SwitchAddressButton(switchContactAddress)
|
||||
SectionView {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(contact.verified, verifyClicked)
|
||||
SectionDivider()
|
||||
}
|
||||
ContactPreferencesButton(openPreferences)
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SwitchAddressButton(switchContactAddress)
|
||||
SectionDivider()
|
||||
if (connStats != null) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -196,13 +242,17 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (contact.verified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
||||
}
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
@@ -264,7 +314,7 @@ fun LocalAliasEditor(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -296,7 +346,7 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
private fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
Box(Modifier.size(18.dp)) {
|
||||
when (networkStatus) {
|
||||
is Chat.NetworkStatus.Connected ->
|
||||
@@ -327,6 +377,25 @@ fun SwitchAddressButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
|
||||
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
||||
click = onClick,
|
||||
iconColor = HighOrLowlight,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.contact_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
@@ -339,7 +408,7 @@ fun ClearChatButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteContactButton(onClick: () -> Unit) {
|
||||
private fun DeleteContactButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_contact),
|
||||
@@ -382,13 +451,16 @@ fun PreviewChatInfoLayout() {
|
||||
),
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
connectionCode = "123",
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
openPreferences = {},
|
||||
deleteContact = {},
|
||||
clearChat = {},
|
||||
switchContactAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
@@ -14,8 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
@@ -52,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()
|
||||
@@ -63,7 +61,6 @@ 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
|
||||
@@ -80,16 +77,23 @@ fun ChatView(chatModel: 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
|
||||
}}
|
||||
snapshotFlow {
|
||||
/**
|
||||
* It's possible that in some cases concurrent modification can happen on [ChatModel.chats] list.
|
||||
* In this case only error log will be printed here (no crash).
|
||||
* TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
|
||||
* */
|
||||
try {
|
||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||
} catch (e: ConcurrentModificationException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
null
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
|
||||
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
|
||||
.collect { activeChat.value = it }
|
||||
}
|
||||
}
|
||||
val view = LocalView.current
|
||||
@@ -97,17 +101,15 @@ fun ChatView(chatModel: ChatModel) {
|
||||
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,
|
||||
@@ -120,29 +122,33 @@ fun ChatView(chatModel: ChatModel) {
|
||||
}
|
||||
},
|
||||
attachmentOption,
|
||||
scope,
|
||||
attachmentBottomSheetState,
|
||||
chatModel.chatItems,
|
||||
searchText,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
linkMode = chatModel.simplexLinkMode.value,
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
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)
|
||||
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
|
||||
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
|
||||
}
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
setGroupMembers(cInfo.groupInfo, chatModel)
|
||||
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
||||
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupChatInfoView(chatModel, close)
|
||||
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,8 +157,21 @@ fun ChatView(chatModel: ChatModel) {
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val (_, code) = if (member.memberActive) {
|
||||
try {
|
||||
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
member to null
|
||||
}
|
||||
} else {
|
||||
member to null
|
||||
}
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -168,13 +187,20 @@ fun ChatView(chatModel: ChatModel) {
|
||||
deleteMessage = { itemId, mode ->
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
val toItem = chatModel.controller.apiDeleteChatItem(
|
||||
val r = chatModel.controller.apiDeleteChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = itemId,
|
||||
mode = mode
|
||||
)
|
||||
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
|
||||
if (r != null) {
|
||||
val toChatItem = r.toChatItem
|
||||
if (toChatItem == null) {
|
||||
chatModel.removeChatItem(cInfo, r.deletedChatItem.chatItem)
|
||||
} else {
|
||||
chatModel.upsertChatItem(cInfo, toChatItem.chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
receiveFile = { fileId ->
|
||||
@@ -200,19 +226,24 @@ fun ChatView(chatModel: ChatModel) {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
},
|
||||
acceptFeature = { contact, feature, param ->
|
||||
withApi {
|
||||
chatModel.controller.allowFeatureToContact(contact, feature, param)
|
||||
}
|
||||
},
|
||||
addMembers = { groupInfo ->
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
markRead = { range, unreadCountAfter ->
|
||||
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
withApi {
|
||||
withBGApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
@@ -228,24 +259,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,
|
||||
@@ -256,12 +287,15 @@ fun ChatLayout(
|
||||
joinGroup: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
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)
|
||||
@@ -292,9 +326,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, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -417,10 +451,15 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
|
||||
ContactVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
|
||||
Text(
|
||||
cInfo.fullName,
|
||||
@@ -431,6 +470,11 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactVerifiedShield() {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
|
||||
}
|
||||
|
||||
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
||||
|
||||
val CIListStateSaver = run {
|
||||
@@ -445,13 +489,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,
|
||||
@@ -459,14 +503,14 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> 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)
|
||||
ScrollToBottom(chat.id, listState, chatItems)
|
||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||
// Scroll to bottom when search value changes from something to nothing and back
|
||||
LaunchedEffect(searchValue.value.isEmpty()) {
|
||||
@@ -493,8 +537,18 @@ 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 ->
|
||||
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
@@ -555,11 +609,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, acceptFeature = acceptFeature, 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, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
@@ -570,7 +624,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, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +643,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
|
||||
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||
// and prevents scrolling to bottom on orientation change
|
||||
@@ -601,6 +655,23 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
|
||||
// Don't autoscroll next time until it will be needed
|
||||
shouldAutoScroll = false to chatId
|
||||
}
|
||||
val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
|
||||
/*
|
||||
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
|
||||
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
|
||||
* */
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatItems.lastOrNull()?.id }
|
||||
.distinctUntilChanged()
|
||||
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
|
||||
.collect {
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
} else {
|
||||
listState.animateScrollBy(scrollDistance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -717,7 +788,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
|
||||
@@ -931,7 +1002,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,
|
||||
@@ -941,11 +1011,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 = {},
|
||||
@@ -956,10 +1026,12 @@ fun PreviewChatLayout() {
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
onComposed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -989,7 +1061,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,
|
||||
@@ -999,11 +1070,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 = {},
|
||||
@@ -1014,10 +1085,12 @@ fun PreviewGroupChatLayout() {
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
onComposed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
@@ -14,14 +16,11 @@ import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
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.filled.*
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
@@ -29,26 +28,31 @@ 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.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
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -59,16 +63,25 @@ sealed class ComposeContextItem {
|
||||
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LiveMessage(
|
||||
val chatItem: ChatItem,
|
||||
val typedMsg: String,
|
||||
val sentMsg: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ComposeState(
|
||||
val message: String = "",
|
||||
val liveMessage: LiveMessage? = null,
|
||||
val preview: ComposePreview = ComposePreview.NoPreview,
|
||||
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
||||
val inProgress: Boolean = false,
|
||||
val useLinkPreviews: Boolean
|
||||
) {
|
||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
|
||||
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
|
||||
editingItem.content.text,
|
||||
liveMessage,
|
||||
chatItemPreview(editingItem),
|
||||
ComposeContextItem.EditingItem(editingItem),
|
||||
useLinkPreviews = useLinkPreviews
|
||||
@@ -84,8 +97,9 @@ data class ComposeState(
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty()
|
||||
else -> message.isNotEmpty() || liveMessage != null
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
@@ -93,6 +107,7 @@ data class ComposeState(
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.VoicePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
}
|
||||
@@ -103,6 +118,16 @@ data class ComposeState(
|
||||
else -> null
|
||||
}
|
||||
|
||||
val attachmentDisabled: Boolean
|
||||
get() {
|
||||
if (editing || liveMessage != null) return true
|
||||
return when (preview) {
|
||||
ComposePreview.NoPreview -> false
|
||||
is ComposePreview.CLinkPreview -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
||||
save = { json.encodeToString(serializer(), it.value) },
|
||||
@@ -113,16 +138,26 @@ data class ComposeState(
|
||||
}
|
||||
}
|
||||
|
||||
sealed class RecordingState {
|
||||
object NotStarted: RecordingState()
|
||||
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
|
||||
class Finished(val filePath: String, val durationMs: Int): RecordingState()
|
||||
|
||||
val filePathNullable: String?
|
||||
get() = (this as? Started)?.filePath
|
||||
}
|
||||
|
||||
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
return when (val mc = chatItem.content.msgContent) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +179,11 @@ fun ComposeView(
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
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) {
|
||||
@@ -156,7 +196,7 @@ fun ComposeView(
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launch(null)
|
||||
cameraLauncher.launchWithFallback()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -222,13 +262,14 @@ fun ComposeView(
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
AttachmentOption.TakePhoto -> {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launch(null)
|
||||
cameraLauncher.launchWithFallback()
|
||||
}
|
||||
else -> {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
@@ -299,117 +340,157 @@ fun ComposeView(
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
fun checkLinkPreview(): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (val composePreview = cs.preview) {
|
||||
is ComposePreview.CLinkPreview -> {
|
||||
val url = parseMessage(cs.message)
|
||||
val lp = composePreview.linkPreview
|
||||
if (lp != null && url == lp.uri) {
|
||||
MsgContent.MCLink(cs.message, preview = lp)
|
||||
} else {
|
||||
MsgContent.MCText(cs.message)
|
||||
}
|
||||
}
|
||||
else -> MsgContent.MCText(cs.message)
|
||||
fun clearState(live: Boolean = false) {
|
||||
if (live) {
|
||||
composeState.value = composeState.value.copy(inProgress = false)
|
||||
} else {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
resetLinkPreview()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (msgContent) {
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearState() {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
recState.value = RecordingState.NotStarted
|
||||
textStyle.value = smallFont
|
||||
chosenContent.value = emptyList()
|
||||
chosenAudio.value = null
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
pendingLinkUrl.value = null
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
|
||||
val aChatItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = file,
|
||||
quotedItemId = quoted,
|
||||
mc = mc,
|
||||
live = live
|
||||
)
|
||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||
return aChatItem?.chatItem
|
||||
}
|
||||
|
||||
|
||||
|
||||
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
var sent: ChatItem?
|
||||
val msgText = text ?: cs.message
|
||||
|
||||
fun sending() {
|
||||
composeState.value = composeState.value.copy(inProgress = true)
|
||||
}
|
||||
|
||||
fun checkLinkPreview(): MsgContent {
|
||||
return when (val composePreview = cs.preview) {
|
||||
is ComposePreview.CLinkPreview -> {
|
||||
val url = parseMessage(msgText)
|
||||
val lp = composePreview.linkPreview
|
||||
if (lp != null && url == lp.uri) {
|
||||
MsgContent.MCLink(msgText, preview = lp)
|
||||
} else {
|
||||
MsgContent.MCText(msgText)
|
||||
}
|
||||
}
|
||||
else -> MsgContent.MCText(msgText)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||
return when (msgContent) {
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
||||
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
|
||||
val oldMsgContent = ei.content.msgContent
|
||||
if (oldMsgContent != null) {
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
mc = updateMsgContent(oldMsgContent),
|
||||
live = live
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
return updatedItem?.chatItem
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!live) {
|
||||
sending()
|
||||
}
|
||||
|
||||
if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||
val ei = cs.contextItem.chatItem
|
||||
sent = updateMessage(ei, cInfo, live)
|
||||
} else if (cs.liveMessage != null) {
|
||||
sent = updateMessage(cs.liveMessage.chatItem, cInfo, live)
|
||||
} else {
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
val files: ArrayList<String> = ArrayList()
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (chosenContent.value.lastIndex == index) msgText else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
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())
|
||||
AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath)
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", chosenAudioVal.second / 1000))
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
val file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val quotedItemId: Long? = when (cs.contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id
|
||||
else -> null
|
||||
}
|
||||
sent = null
|
||||
msgs.forEachIndexed { index, content ->
|
||||
if (index > 0) delay(100)
|
||||
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
|
||||
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
||||
)
|
||||
}
|
||||
if (sent == null && chosenContent.value.isNotEmpty()) {
|
||||
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||
}
|
||||
}
|
||||
clearState(live)
|
||||
return sent
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
composeState.value = composeState.value.copy(inProgress = true)
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
when (val contextItem = cs.contextItem) {
|
||||
is ComposeContextItem.EditingItem -> {
|
||||
val ei = contextItem.chatItem
|
||||
val oldMsgContent = ei.content.msgContent
|
||||
if (oldMsgContent != null) {
|
||||
withApi {
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
mc = updateMsgContent(oldMsgContent)
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
val files: ArrayList<String> = ArrayList()
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
val file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val quotedItemId: Long? = when (contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
||||
else -> null
|
||||
}
|
||||
if (msgs.isNotEmpty()) {
|
||||
withApi {
|
||||
msgs.forEachIndexed { index, content ->
|
||||
if (index > 0) delay(100)
|
||||
val aChatItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = files.getOrNull(index),
|
||||
quotedItemId = if (index == 0) quotedItemId else null,
|
||||
mc = content
|
||||
)
|
||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||
}
|
||||
clearState()
|
||||
}
|
||||
} else {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
withBGApi {
|
||||
sendMessageAsync(null, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +507,20 @@ 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
|
||||
withApi {
|
||||
chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelLinkPreview() {
|
||||
val uri = composeState.value.linkPreview?.uri
|
||||
if (uri != null) {
|
||||
@@ -440,11 +535,69 @@ fun ComposeView(
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
val filePath = recState.value.filePathNullable
|
||||
recState.value = RecordingState.NotStarted
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
withBGApi {
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
AudioPlayer.stop(filePath)
|
||||
filePath?.let { File(it).delete() }
|
||||
}
|
||||
chosenAudio.value = null
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenFile.value = null
|
||||
}
|
||||
|
||||
fun truncateToWords(s: String): String {
|
||||
var acc = ""
|
||||
val word = StringBuilder()
|
||||
for (c in s) {
|
||||
if (c.isLetter() || c.isDigit()) {
|
||||
word.append(c)
|
||||
} else {
|
||||
acc = acc + word.toString() + c
|
||||
word.clear()
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
suspend fun sendLiveMessage() {
|
||||
val typedMsg = composeState.value.message
|
||||
val sentMsg = truncateToWords(typedMsg)
|
||||
if (composeState.value.liveMessage == null) {
|
||||
val ci = sendMessageAsync(sentMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun liveMessageToSend(lm: LiveMessage, t: String): String? {
|
||||
val s = if (t != lm.typedMsg) truncateToWords(t) else t
|
||||
return if (s != lm.sentMsg) s else null
|
||||
}
|
||||
|
||||
suspend fun updateLiveMessage() {
|
||||
val typedMsg = composeState.value.message
|
||||
val liveMessage = composeState.value.liveMessage
|
||||
if (liveMessage != null) {
|
||||
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
|
||||
if (sentMsg != null) {
|
||||
val ci = sendMessageAsync(sentMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
|
||||
}
|
||||
} else if (liveMessage.typedMsg != typedMsg) {
|
||||
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun previewView() {
|
||||
when (val preview = composeState.value.preview) {
|
||||
@@ -455,6 +608,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,
|
||||
@@ -477,6 +637,9 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
LaunchedEffect(chatModel.sharedContent.value) {
|
||||
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
|
||||
if (chatModel.chatId.value == null) return@LaunchedEffect
|
||||
|
||||
when (val shared = chatModel.sharedContent.value) {
|
||||
is SharedContent.Text -> onMessageChange(shared.text)
|
||||
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
|
||||
@@ -489,38 +652,77 @@ 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)) {
|
||||
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
||||
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
if (attachEnabled) {
|
||||
showChooseAttachment()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
|
||||
LaunchedEffect(allowedVoiceByPrefs) {
|
||||
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
|
||||
// Voice was disabled right when this user records it, just cancel it
|
||||
cancelVoice()
|
||||
}
|
||||
}
|
||||
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||
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
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { recState.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
when(it) {
|
||||
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
|
||||
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
|
||||
is RecordingState.NotStarted -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val activity = LocalContext.current as Activity
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
if (orientation == activity.resources.configuration.orientation && composeState.value.liveMessage != null) {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SendMsgView(
|
||||
composeState,
|
||||
showVoiceRecordIcon = true,
|
||||
recState,
|
||||
chat.chatInfo is ChatInfo.Direct,
|
||||
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
|
||||
needToAllowVoiceToContact,
|
||||
allowedVoiceByPrefs,
|
||||
allowVoiceToContact = ::allowVoiceToContact,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
::onMessageChange,
|
||||
textStyle
|
||||
sendLiveMessage = ::sendLiveMessage,
|
||||
updateLiveMessage = ::updateLiveMessage,
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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.model.durationText
|
||||
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 {
|
||||
finishedRecording -> progress.value
|
||||
else -> recordedDurationMs
|
||||
}
|
||||
val endTime = when {
|
||||
finishedRecording -> duration.value
|
||||
audioPlaying.value -> recordedDurationMs
|
||||
else -> MAX_VOICE_MILLIS_FOR_SENDING
|
||||
}
|
||||
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, false)
|
||||
} 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 {
|
||||
when {
|
||||
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
|
||||
finishedRecording -> progress.value / 1000
|
||||
else -> recordedDurationMs / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
durationText(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,232 @@
|
||||
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.runtime.saveable.rememberSaveable
|
||||
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.*
|
||||
import chat.simplex.app.views.usersettings.PreferenceToggle
|
||||
|
||||
@Composable
|
||||
fun ContactPreferencesView(
|
||||
m: ChatModel,
|
||||
user: User,
|
||||
contactId: Long,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||
val ct = contact.value ?: return
|
||||
var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
|
||||
var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
m.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (featuresAllowed == currentFeaturesAllowed) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
ContactPreferencesLayout(
|
||||
featuresAllowed,
|
||||
currentFeaturesAllowed,
|
||||
user,
|
||||
ct,
|
||||
applyPrefs = { prefs ->
|
||||
featuresAllowed = prefs
|
||||
},
|
||||
reset = {
|
||||
featuresAllowed = currentFeaturesAllowed
|
||||
},
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
||||
val onTTLUpdated = { ttl: Int? ->
|
||||
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400))
|
||||
}
|
||||
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
|
||||
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
||||
FeatureSection(ChatFeature.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(ChatFeature.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: ChatFeature,
|
||||
userDefault: FeatureAllowed,
|
||||
pref: ContactUserPreference,
|
||||
allowFeature: State<ContactFeatureAllowed>,
|
||||
onSelected: (ContactFeatureAllowed) -> Unit
|
||||
) {
|
||||
val enabled = FeatureEnabled.enabled(
|
||||
feature.asymmetric,
|
||||
user = SimpleChatPreference(allow = allowFeature.value.allowed),
|
||||
contact = pref.contactPreference
|
||||
)
|
||||
|
||||
SectionView(
|
||||
feature.text.uppercase(),
|
||||
icon = feature.iconFilled,
|
||||
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 TimedMessagesFeatureSection(
|
||||
featuresAllowed: ContactFeaturesAllowed,
|
||||
pref: ContactUserPreferenceTimed,
|
||||
allowFeature: State<Boolean>,
|
||||
onTTLUpdated: (Int?) -> Unit,
|
||||
onSelected: (Boolean, Int?) -> Unit
|
||||
) {
|
||||
val enabled = FeatureEnabled.enabled(
|
||||
ChatFeature.TimedMessages.asymmetric,
|
||||
user = TimedMessagesPreference(allow = if (allowFeature.value) FeatureAllowed.YES else FeatureAllowed.NO),
|
||||
contact = pref.contactPreference
|
||||
)
|
||||
|
||||
SectionView(
|
||||
ChatFeature.TimedMessages.text.uppercase(),
|
||||
icon = ChatFeature.TimedMessages.iconFilled,
|
||||
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||
leadingIcon = true,
|
||||
) {
|
||||
SectionItemView {
|
||||
PreferenceToggle(
|
||||
generalGetString(R.string.chat_preferences_you_allow),
|
||||
checked = allowFeature.value,
|
||||
) { allow ->
|
||||
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
InfoRow(
|
||||
generalGetString(R.string.chat_preferences_contact_allows),
|
||||
pref.contactPreference.allow.text
|
||||
)
|
||||
SectionDivider()
|
||||
if (featuresAllowed.timedMessagesAllowed) {
|
||||
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
|
||||
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
|
||||
}
|
||||
}
|
||||
SectionTextFooter(ChatFeature.TimedMessages.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
|
||||
val ttlValues = TimedMessagesPreference.ttlValues
|
||||
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.delete_after),
|
||||
values.map { it to TimedMessagesPreference.ttlText(it) },
|
||||
selection,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_contact),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
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 ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ScanCodeLayout(verifyCode, close)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.scan_code), false)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
) {
|
||||
QRCodeScanner { text ->
|
||||
verifyCode(text) {
|
||||
if (it) {
|
||||
close()
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.incorrect_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.scan_code_from_contacts_app))
|
||||
}
|
||||
}
|
||||
@@ -1,157 +1,507 @@
|
||||
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
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.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.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
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.unit.*
|
||||
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.model.*
|
||||
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.chat.item.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
showVoiceRecordIcon: Boolean,
|
||||
recState: MutableState<RecordingState>,
|
||||
isDirectChat: Boolean,
|
||||
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||
needToAllowVoiceToContact: Boolean,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
sendMessage: () -> Unit,
|
||||
sendLiveMessage: ( suspend () -> Unit)? = null,
|
||||
updateLiveMessage: (suspend () -> Unit)? = null,
|
||||
onMessageChange: (String) -> Unit,
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
val cs = composeState.value
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
when (cs.contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> {
|
||||
delay(100)
|
||||
showKeyboard = true
|
||||
Box(Modifier.padding(vertical = 8.dp)) {
|
||||
val cs = composeState.value
|
||||
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
// Disable clicks on text field
|
||||
if (cs.preview is ComposePreview.VoicePreview) {
|
||||
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val sendButtonSize = remember { Animatable(36f) }
|
||||
val sendButtonAlpha = remember { Animatable(1f) }
|
||||
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
// Making LiveMessage alive when screen orientation was changed
|
||||
if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
}
|
||||
}
|
||||
is ComposeContextItem.EditingItem -> {
|
||||
// Keyboard will not show up if we try to show it too fast
|
||||
delay(300)
|
||||
showKeyboard = true
|
||||
when {
|
||||
showProgress -> ProgressIndicator()
|
||||
showVoiceButton -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||
when {
|
||||
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
|
||||
DisallowedVoiceButton {
|
||||
if (needToAllowVoiceToContact) {
|
||||
showNeedToAllowVoiceAlert(allowVoiceToContact)
|
||||
} else {
|
||||
showDisabledVoiceAlert(isDirectChat)
|
||||
}
|
||||
}
|
||||
}
|
||||
!permissionsState.allPermissionsGranted ->
|
||||
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
|
||||
else ->
|
||||
RecordVoiceView(recState, stopRecOnNextClick)
|
||||
}
|
||||
if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StartLiveMessageButton {
|
||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (composeState.value.liveMessage == null &&
|
||||
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
|
||||
sendLiveMessage != null && updateLiveMessage != null
|
||||
) {
|
||||
var showDropdown by rememberSaveable { mutableStateOf(false) }
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = { showDropdown = false },
|
||||
Modifier.width(220.dp),
|
||||
) {
|
||||
ItemAction(
|
||||
generalGetString(R.string.send_live_message),
|
||||
Icons.Filled.Bolt,
|
||||
onClick = {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
showDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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() }
|
||||
|
||||
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
|
||||
}
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||
delay(100)
|
||||
showKeyboard = true
|
||||
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||
// Keyboard will not show up if we try to show it too fast
|
||||
delay(300)
|
||||
showKeyboard = true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
|
||||
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||
DisposableEffect(Unit) { onDispose { rec.stop() } }
|
||||
val stopRecordingAndAddAudio: () -> Unit = {
|
||||
recState.value.filePathNullable?.let {
|
||||
recState.value = RecordingState.Finished(it, rec.stop())
|
||||
}
|
||||
}
|
||||
if (stopRecOnNextClick.value) {
|
||||
LaunchedEffect(recState.value) {
|
||||
if (recState.value is RecordingState.NotStarted) {
|
||||
stopRecOnNextClick.value = false
|
||||
}
|
||||
}
|
||||
// Lock orientation to current orientation because screen rotation will break the recording
|
||||
LockToCurrentOrientationUntilDispose()
|
||||
StopRecordButton(stopRecordingAndAddAudio)
|
||||
} else {
|
||||
val startRecording: () -> Unit = {
|
||||
recState.value = RecordingState.Started(
|
||||
filePath = rec.start { progress: Int?, finished: Boolean ->
|
||||
val state = recState.value
|
||||
if (state is RecordingState.Started && progress != null) {
|
||||
recState.value = if (!finished)
|
||||
RecordingState.Started(state.filePath, progress)
|
||||
else
|
||||
RecordingState.Finished(state.filePath, progress)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val interactionSource = interactionSourceWithTapDetection(
|
||||
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
|
||||
onClick = {
|
||||
if (stopRecOnNextClick.value) {
|
||||
stopRecordingAndAddAudio()
|
||||
} else {
|
||||
// tapped and didn't hold a finger
|
||||
stopRecOnNextClick.value = true
|
||||
}
|
||||
},
|
||||
onCancel = stopRecordingAndAddAudio,
|
||||
onRelease = stopRecordingAndAddAudio
|
||||
)
|
||||
RecordVoiceButton(interactionSource)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisallowedVoiceButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Outlined.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockToCurrentOrientationUntilDispose() {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as Activity
|
||||
activity.requestedOrientation = when (activity.display?.rotation) {
|
||||
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun StopRecordButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Stop,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
||||
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator() {
|
||||
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendTextButton(
|
||||
icon: ImageVector,
|
||||
backgroundColor: Color,
|
||||
sizeDp: Animatable<Float, AnimationVector1D>,
|
||||
alpha: Animatable<Float, AnimationVector1D>,
|
||||
enabled: Boolean,
|
||||
sendMessage: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.combinedClickable(
|
||||
onClick = sendMessage,
|
||||
onLongClick = onLongClick,
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(sizeDp.value.dp)
|
||||
.padding(4.dp)
|
||||
.alpha(alpha.value)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.padding(3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartLiveMessageButton(onClick: () -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
enabled = true,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Bolt,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLiveMessage(
|
||||
scope: CoroutineScope,
|
||||
send: suspend () -> Unit,
|
||||
update: suspend () -> Unit,
|
||||
sendButtonSize: Animatable<Float, AnimationVector1D>,
|
||||
sendButtonAlpha: Animatable<Float, AnimationVector1D>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
liveMessageAlertShown: SharedPreference<Boolean>
|
||||
) {
|
||||
fun run() {
|
||||
scope.launch {
|
||||
while (composeState.value.liveMessage != null) {
|
||||
sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50))
|
||||
}
|
||||
sendButtonSize.snapTo(36f)
|
||||
}
|
||||
scope.launch {
|
||||
while (composeState.value.liveMessage != null) {
|
||||
sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50))
|
||||
}
|
||||
sendButtonAlpha.snapTo(1f)
|
||||
}
|
||||
scope.launch {
|
||||
while (composeState.value.liveMessage != null) {
|
||||
delay(3000)
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() = withBGApi {
|
||||
if (composeState.value.liveMessage == null) {
|
||||
send()
|
||||
}
|
||||
run()
|
||||
}
|
||||
|
||||
if (liveMessageAlertShown.state.value) {
|
||||
start()
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.live_message),
|
||||
text = generalGetString(R.string.send_live_message_desc),
|
||||
confirmText = generalGetString(R.string.send_verb),
|
||||
onConfirm = {
|
||||
liveMessageAlertShown.set(true)
|
||||
start()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
|
||||
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 = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.voice_messages_prohibited),
|
||||
text = generalGetString(
|
||||
if (isDirectChat)
|
||||
R.string.ask_your_contact_to_enable_voice
|
||||
else
|
||||
R.string.only_group_owners_can_enable_voice
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@@ -167,6 +517,13 @@ fun PreviewSendMsgView() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
showVoiceRecordIcon = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
@@ -188,6 +545,13 @@ fun PreviewSendMsgViewEditing() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
showVoiceRecordIcon = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
@@ -209,6 +573,13 @@ fun PreviewSendMsgViewInProgress() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
showVoiceRecordIcon = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun VerifyCodeView(
|
||||
displayName: String,
|
||||
connectionCode: String?,
|
||||
connectionVerified: Boolean,
|
||||
verify: suspend (String?) -> Pair<Boolean, String>?,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeLayout(
|
||||
displayName,
|
||||
connectionCode,
|
||||
connectionVerified,
|
||||
verifyCode = { newCode, cb ->
|
||||
withBGApi {
|
||||
val res = verify(newCode)
|
||||
if (res != null) {
|
||||
val (verified) = res
|
||||
cb(verified)
|
||||
if (verified) close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerifyCodeLayout(
|
||||
displayName: String,
|
||||
connectionCode: String,
|
||||
connectionVerified: Boolean,
|
||||
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.security_code), false)
|
||||
val splitCode = splitToParts(connectionCode, 24)
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
||||
if (connectionVerified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight)
|
||||
Text(String.format(stringResource(R.string.is_verified), displayName))
|
||||
} else {
|
||||
Text(String.format(stringResource(R.string.is_not_verified), displayName))
|
||||
}
|
||||
}
|
||||
|
||||
SectionView {
|
||||
QRCode(connectionCode, Modifier.aspectRatio(1f))
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.weight(2f))
|
||||
SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) {
|
||||
Text(
|
||||
splitCode,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 20
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
Box(Modifier.weight(1f)) {
|
||||
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
||||
Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Text(
|
||||
generalGetString(R.string.to_verify_compare),
|
||||
Modifier.padding(bottom = DEFAULT_PADDING)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.padding(bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (connectionVerified) {
|
||||
SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) {
|
||||
verifyCode(null) {}
|
||||
}
|
||||
} else {
|
||||
SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) {
|
||||
ModalManager.shared.showModal {
|
||||
ScanCodeView(verifyCode) { }
|
||||
}
|
||||
}
|
||||
SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) {
|
||||
verifyCode(connectionCode) { verified ->
|
||||
if (!verified) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.incorrect_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitToParts(s: String, length: Int): String {
|
||||
if (length >= s.length) return s
|
||||
return (0..(s.length - 1) / length)
|
||||
.map { s.drop(it * length).take(length) }
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
@@ -31,17 +31,23 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
|
||||
@Composable
|
||||
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
||||
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, 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,
|
||||
creatingGroup = creatingGroup,
|
||||
contactsToAdd = getContactsToAdd(chatModel),
|
||||
selectedContacts = selectedContacts,
|
||||
selectedRole = selectedRole,
|
||||
allowModifyMembers = allowModifyMembers,
|
||||
openPreferences = {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
GroupPreferencesView(chatModel, groupInfo.id, close)
|
||||
}
|
||||
},
|
||||
inviteMembers = {
|
||||
allowModifyMembers = false
|
||||
withApi {
|
||||
@@ -59,6 +65,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
|
||||
clearSelection = { selectedContacts.clear() },
|
||||
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
|
||||
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
||||
close = close,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,14 +86,17 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
||||
@Composable
|
||||
fun AddGroupMembersLayout(
|
||||
groupInfo: GroupInfo,
|
||||
creatingGroup: Boolean,
|
||||
contactsToAdd: List<Contact>,
|
||||
selectedContacts: List<Long>,
|
||||
selectedRole: MutableState<GroupMemberRole>,
|
||||
allowModifyMembers: Boolean,
|
||||
openPreferences: () -> Unit,
|
||||
inviteMembers: () -> Unit,
|
||||
clearSelection: () -> Unit,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -120,18 +130,28 @@ fun AddGroupMembersLayout(
|
||||
}
|
||||
} else {
|
||||
SectionView {
|
||||
if (creatingGroup) {
|
||||
SectionItemView(openPreferences) {
|
||||
Text(stringResource(R.string.set_group_preferences))
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
SectionItemView {
|
||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||
}
|
||||
SectionDivider()
|
||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
|
||||
if (creatingGroup && selectedContacts.isEmpty()) {
|
||||
SkipInvitingButton(close)
|
||||
} else {
|
||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
|
||||
}
|
||||
}
|
||||
SectionCustomFooter {
|
||||
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
SectionView(stringResource(R.string.select_contacts)) {
|
||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
@@ -170,6 +190,17 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SkipInvitingButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Check,
|
||||
stringResource(R.string.skip_inviting_button),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
|
||||
Row(
|
||||
@@ -288,14 +319,17 @@ fun PreviewAddGroupMembersLayout() {
|
||||
SimpleXTheme {
|
||||
AddGroupMembersLayout(
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
creatingGroup = false,
|
||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||
selectedContacts = remember { mutableStateListOf() },
|
||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||
allowModifyMembers = true,
|
||||
openPreferences = {},
|
||||
inviteMembers = {},
|
||||
clearSelection = {},
|
||||
addContact = {},
|
||||
removeContact = {}
|
||||
removeContact = {},
|
||||
close = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -22,15 +24,17 @@ 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.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
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) {
|
||||
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdated: (String?) -> Unit, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
@@ -43,32 +47,56 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||
.sortedBy { it.displayName.lowercase() },
|
||||
developerTools,
|
||||
groupLink,
|
||||
addMembers = {
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
showMemberInfo = { member ->
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val (_, code) = if (member.memberActive) {
|
||||
try {
|
||||
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
member to null
|
||||
}
|
||||
} else {
|
||||
member to null
|
||||
}
|
||||
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
|
||||
closeCurrent()
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editGroupProfile = {
|
||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
GroupPreferencesView(
|
||||
chatModel,
|
||||
chat.id,
|
||||
close
|
||||
)
|
||||
}
|
||||
},
|
||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
manageGroupLink = {
|
||||
withApi {
|
||||
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -117,9 +145,11 @@ fun GroupChatInfoLayout(
|
||||
groupInfo: GroupInfo,
|
||||
members: List<GroupMember>,
|
||||
developerTools: Boolean,
|
||||
groupLink: String?,
|
||||
addMembers: () -> Unit,
|
||||
showMemberInfo: (GroupMember) -> Unit,
|
||||
editGroupProfile: () -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteGroup: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
leaveGroup: () -> Unit,
|
||||
@@ -139,9 +169,25 @@ 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() }
|
||||
SectionItemView(manageGroupLink) {
|
||||
if (groupLink == null) {
|
||||
CreateGroupLinkButton()
|
||||
} else {
|
||||
GroupLinkButton()
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
||||
SectionItemView(onAddMembersClick) {
|
||||
@@ -160,10 +206,6 @@ fun GroupChatInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
||||
SectionDivider()
|
||||
}
|
||||
ClearChatButton(clearChat)
|
||||
if (groupInfo.canDelete) {
|
||||
SectionDivider()
|
||||
@@ -188,7 +230,7 @@ fun GroupChatInfoLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
@@ -212,7 +254,16 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.group_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -228,7 +279,7 @@ fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||
Column {
|
||||
members.forEachIndexed { index, member ->
|
||||
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
|
||||
@@ -242,7 +293,7 @@ fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Uni
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -254,10 +305,15 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
) {
|
||||
ProfileImage(size = 46.dp, member.image)
|
||||
Column {
|
||||
Text(
|
||||
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (member.verified) {
|
||||
MemberVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
)
|
||||
}
|
||||
val s = member.memberStatus.shortText
|
||||
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||
Text(
|
||||
@@ -277,7 +333,12 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupLinkButton() {
|
||||
private fun MemberVerifiedShield() {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupLinkButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
@@ -286,10 +347,27 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateGroupLinkButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.AddLink,
|
||||
stringResource(R.string.create_group_link),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.create_group_link))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,15 +381,15 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveGroupButton() {
|
||||
private fun LeaveGroupButton() {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -327,7 +405,7 @@ fun LeaveGroupButton() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteGroupButton() {
|
||||
private fun DeleteGroupButton() {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -355,7 +433,8 @@ fun PreviewGroupChatInfoLayout() {
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
groupLink = null,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Text
|
||||
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
|
||||
@@ -15,22 +17,32 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.GroupInfo
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?) {
|
||||
var groupLink by remember { mutableStateOf(connReqContact) }
|
||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) {
|
||||
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
||||
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||
val cxt = LocalContext.current
|
||||
fun createLink() {
|
||||
creatingLink = true
|
||||
withApi {
|
||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
onGroupLinkUpdated(groupLink)
|
||||
creatingLink = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (groupLink == null && !creatingLink) {
|
||||
createLink()
|
||||
}
|
||||
}
|
||||
GroupLinkLayout(
|
||||
groupLink = groupLink,
|
||||
createLink = {
|
||||
withApi {
|
||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
}
|
||||
},
|
||||
creatingLink,
|
||||
createLink = ::createLink,
|
||||
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||
deleteLink = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -42,17 +54,22 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
||||
if (r) {
|
||||
groupLink = null
|
||||
onGroupLinkUpdated(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
if (creatingLink) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupLinkLayout(
|
||||
groupLink: String?,
|
||||
creatingLink: Boolean,
|
||||
createLink: () -> Unit,
|
||||
share: () -> Unit,
|
||||
deleteLink: () -> Unit
|
||||
@@ -74,7 +91,7 @@ fun GroupLinkLayout(
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (groupLink == null) {
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
|
||||
} else {
|
||||
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
||||
Row(
|
||||
@@ -99,3 +116,18 @@ fun GroupLinkLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressIndicator() {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -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
|
||||
@@ -21,19 +23,20 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.SimplexServers
|
||||
import chat.simplex.app.views.chat.SwitchAddressButton
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoView(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
connectionCode: String?,
|
||||
chatModel: ChatModel,
|
||||
close: () -> Unit,
|
||||
closeAll: () -> Unit, // Close all open windows up to ChatView
|
||||
@@ -49,20 +52,27 @@ fun GroupMemberInfoView(
|
||||
connStats,
|
||||
newRole,
|
||||
developerTools,
|
||||
openDirectChat = {
|
||||
connectionCode,
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
knownDirectChat = {
|
||||
withApi {
|
||||
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
|
||||
if (oldChat != null) {
|
||||
openChat(oldChat.chatInfo, chatModel)
|
||||
} else {
|
||||
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(it.chatItems)
|
||||
chatModel.chatId.value = it.chatInfo.id
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
newDirectChat = {
|
||||
withApi {
|
||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||
val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||
chatModel.addChat(newChat)
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatId.value = newChat.id
|
||||
closeAll()
|
||||
}
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
||||
@@ -85,6 +95,32 @@ fun GroupMemberInfoView(
|
||||
},
|
||||
switchMemberAddress = {
|
||||
switchMemberAddress(chatModel, groupInfo, member)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
VerifyCodeView(
|
||||
mem.displayName,
|
||||
connectionCode,
|
||||
mem.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.upsertGroupMember(
|
||||
groupInfo,
|
||||
mem.copy(
|
||||
activeConn = mem.activeConn?.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -114,10 +150,14 @@ fun GroupMemberInfoLayout(
|
||||
connStats: ConnectionStats?,
|
||||
newRole: MutableState<GroupMemberRole>,
|
||||
developerTools: Boolean,
|
||||
openDirectChat: () -> Unit,
|
||||
connectionCode: String?,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
knownDirectChat: (Chat) -> Unit,
|
||||
newDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -133,10 +173,29 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
OpenChatButton(openDirectChat)
|
||||
if (member.memberActive) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId != null) {
|
||||
SectionView {
|
||||
val chat = getContactChat(contactId)
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
|
||||
OpenChatButton(onClick = { knownDirectChat(chat) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
OpenChatButton(onClick = { newDirectChat(contactId) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(member.verified, verifyClicked)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||
@@ -159,12 +218,10 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
if (developerTools) {
|
||||
SwitchAddressButton(switchMemberAddress)
|
||||
SectionDivider()
|
||||
}
|
||||
if (connStats != null) {
|
||||
if (connStats != null) {
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SwitchAddressButton(switchMemberAddress)
|
||||
SectionDivider()
|
||||
val rcvServers = connStats.rcvServers
|
||||
val sndServers = connStats.sndServers
|
||||
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
||||
@@ -179,8 +236,8 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
SectionView {
|
||||
@@ -207,12 +264,17 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (member.verified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
||||
}
|
||||
Text(
|
||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||
Text(
|
||||
member.fullName, style = MaterialTheme.typography.h2,
|
||||
@@ -302,10 +364,14 @@ fun PreviewGroupMemberInfoLayout() {
|
||||
connStats = null,
|
||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||
developerTools = false,
|
||||
openDirectChat = {},
|
||||
connectionCode = "123",
|
||||
getContactChat = { Chat.sampleData },
|
||||
knownDirectChat = {},
|
||||
newDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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.runtime.saveable.rememberSaveable
|
||||
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.chat.TimedMessagesTTLPicker
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
|
||||
|
||||
@Composable
|
||||
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
|
||||
val gInfo = groupInfo.value ?: return
|
||||
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
|
||||
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
|
||||
if (gInfo != null) {
|
||||
m.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (preferences == currentPreferences) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupPreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
gInfo,
|
||||
applyPrefs = { prefs ->
|
||||
preferences = prefs
|
||||
},
|
||||
reset = {
|
||||
preferences = currentPreferences
|
||||
},
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
||||
val onTTLUpdated = { ttl: Int? ->
|
||||
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
|
||||
}
|
||||
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
|
||||
if (enable == GroupFeatureEnabled.ON) {
|
||||
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
|
||||
} else {
|
||||
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
|
||||
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
|
||||
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
||||
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
|
||||
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
|
||||
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||
}
|
||||
if (groupInfo.canEdit) {
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
disabled = preferences == currentPreferences
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(
|
||||
feature: GroupFeature,
|
||||
enableFeature: State<GroupFeatureEnabled>,
|
||||
groupInfo: GroupInfo,
|
||||
preferences: FullGroupPreferences,
|
||||
onTTLUpdated: (Int?) -> Unit,
|
||||
onSelected: (GroupFeatureEnabled) -> Unit
|
||||
) {
|
||||
SectionView {
|
||||
val on = enableFeature.value == GroupFeatureEnabled.ON
|
||||
val icon = if (on) feature.iconFilled else feature.icon
|
||||
val iconTint = if (on) SimplexGreen else HighOrLowlight
|
||||
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView {
|
||||
PreferenceToggleWithIcon(
|
||||
feature.text,
|
||||
icon,
|
||||
iconTint,
|
||||
enableFeature.value == GroupFeatureEnabled.ON,
|
||||
) { checked ->
|
||||
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
|
||||
}
|
||||
}
|
||||
if (timedOn) {
|
||||
SectionDivider()
|
||||
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
|
||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||
}
|
||||
} else {
|
||||
InfoRow(
|
||||
feature.text,
|
||||
enableFeature.value.text,
|
||||
icon = icon,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
if (timedOn) {
|
||||
SectionDivider()
|
||||
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(feature.enableDescription(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,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 +38,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(durationText(duration), color = HighOrLowlight)
|
||||
}
|
||||
CICallStatus.Error -> {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
|
||||
@Composable
|
||||
fun CIChatFeatureView(
|
||||
chatItem: ChatItem,
|
||||
feature: Feature,
|
||||
iconColor: Color,
|
||||
icon: ImageVector? = null
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(18.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,38 @@ 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) }
|
||||
@Composable
|
||||
fun chatEventTextView(text: AnnotatedString) {
|
||||
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
|
||||
}
|
||||
|
||||
Surface {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
val memberDisplayName = ci.memberDisplayName
|
||||
if (memberDisplayName != null) {
|
||||
withGroupEventStyle(this, memberDisplayName)
|
||||
val memberDisplayName = ci.memberDisplayName
|
||||
if (memberDisplayName != null) {
|
||||
chatEventTextView(
|
||||
buildAnnotatedString {
|
||||
withStyle(chatEventStyle) { append(memberDisplayName) }
|
||||
append(" ")
|
||||
}
|
||||
withGroupEventStyle(this, ci.content.text)
|
||||
append(" ")
|
||||
withGroupEventStyle(this, ci.timestampText)
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
|
||||
)
|
||||
}.plus(chatEventText(ci))
|
||||
)
|
||||
} else {
|
||||
chatEventTextView(chatEventText(ci))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
|
||||
|
||||
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun CIFeaturePreferenceView(
|
||||
chatItem: ChatItem,
|
||||
contact: Contact?,
|
||||
feature: ChatFeature,
|
||||
allowed: FeatureAllowed,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = HighOrLowlight)
|
||||
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
||||
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
||||
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
||||
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
|
||||
val param = if (setParam) 86400 else null
|
||||
val annotatedText = buildAnnotatedString {
|
||||
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
|
||||
withAnnotation(tag = "Accept", annotation = "Accept") {
|
||||
withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") }
|
||||
}
|
||||
withStyle(chatEventStyle) { append(chatItem.timestampText) }
|
||||
}
|
||||
fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty()
|
||||
ClickableText(
|
||||
annotatedText,
|
||||
onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } },
|
||||
shouldConsumeEvent = ::accept
|
||||
)
|
||||
} else {
|
||||
Text(chatItem.content.text + " " + chatItem.timestampText,
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,61 +3,85 @@ package chat.simplex.app.views.chat.item
|
||||
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.filled.Circle
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Timer
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!chatItem.isDeletedContent) {
|
||||
if (chatItem.meta.itemEdited) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
modifier = Modifier.height(12.dp).padding(end = 1.dp),
|
||||
contentDescription = stringResource(R.string.icon_descr_edited),
|
||||
tint = metaColor,
|
||||
)
|
||||
}
|
||||
CIStatusView(chatItem.meta.itemStatus, metaColor)
|
||||
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = HighOrLowlight) {
|
||||
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chatItem.isDeletedContent) {
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
color = metaColor,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
} else {
|
||||
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
|
||||
}
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
color = metaColor,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
// changing this function requires updating reserveSpaceForMeta
|
||||
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
||||
if (meta.itemEdited) {
|
||||
StatusIconText(Icons.Outlined.Edit, color)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
}
|
||||
if (meta.disappearing) {
|
||||
StatusIconText(Icons.Outlined.Timer, color)
|
||||
val ttl = meta.itemTimed?.ttl
|
||||
if (ttl != chatTTL) {
|
||||
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
|
||||
if (statusIcon != null) {
|
||||
val (icon, statusColor) = statusIcon
|
||||
StatusIconText(icon, statusColor)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
} else if (!meta.disappearing) {
|
||||
StatusIconText(Icons.Filled.Circle, Color.Transparent)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(meta.timestampText, color = color, fontSize = 13.sp)
|
||||
}
|
||||
|
||||
// the conditions in this function should match CIMetaText
|
||||
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
|
||||
val iconSpace = " "
|
||||
var res = ""
|
||||
if (meta.itemEdited) res += iconSpace
|
||||
if (meta.itemTimed != null) {
|
||||
res += iconSpace
|
||||
val ttl = meta.itemTimed?.ttl
|
||||
if (ttl != chatTTL) {
|
||||
res += TimedMessagesPreference.shortTtlText(ttl)
|
||||
}
|
||||
}
|
||||
if (meta.statusIcon(HighOrLowlight) != null || !meta.disappearing) {
|
||||
res += iconSpace
|
||||
}
|
||||
return res + meta.timestampText
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
||||
when (status) {
|
||||
is CIStatus.SndSent -> {
|
||||
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
|
||||
}
|
||||
is CIStatus.SndErrorAuth -> {
|
||||
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)
|
||||
}
|
||||
is CIStatus.RcvNew -> {
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
private fun StatusIconText(icon: ImageVector, color: Color) {
|
||||
Icon(icon, null, Modifier.height(12.dp), tint = color)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@@ -66,7 +90,8 @@ fun PreviewCIMetaView() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +102,8 @@ fun PreviewCIMetaViewUnread() {
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.RcvNew()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,8 +113,9 @@ fun PreviewCIMetaViewSendFailed() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
|
||||
)
|
||||
status = CIStatus.SndError("CMD SYNTAX")
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,7 +125,8 @@ fun PreviewCIMetaViewSendNoAuth() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,7 +136,8 @@ fun PreviewCIMetaViewSendSent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,7 +148,8 @@ fun PreviewCIMetaViewEdited() {
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,7 +161,8 @@ fun PreviewCIMetaViewEditedUnread() {
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.RcvNew()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,7 +174,8 @@ fun PreviewCIMetaViewEditedSent() {
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.SndSent()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,6 +183,7 @@ fun PreviewCIMetaViewEditedSent() {
|
||||
@Composable
|
||||
fun PreviewCIMetaViewDeletedContent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getDeletedContentSampleData()
|
||||
chatItem = ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
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.unit.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
|
||||
|
||||
@Composable
|
||||
fun CIVoiceView(
|
||||
providedDurationSec: Int,
|
||||
file: CIFile?,
|
||||
edited: Boolean,
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
ci: ChatItem,
|
||||
timedMessagesTTL: Int?,
|
||||
longClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 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 text = remember {
|
||||
derivedStateOf {
|
||||
val time = when {
|
||||
audioPlaying.value || progress.value != 0 -> progress.value
|
||||
else -> duration.value
|
||||
}
|
||||
durationText(time / 1000)
|
||||
}
|
||||
}
|
||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
|
||||
} else {
|
||||
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
|
||||
val metaReserve = if (edited)
|
||||
" "
|
||||
else
|
||||
" "
|
||||
Text(metaReserve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceLayout(
|
||||
file: CIFile,
|
||||
ci: ChatItem,
|
||||
text: State<String>,
|
||||
audioPlaying: State<Boolean>,
|
||||
progress: State<Int>,
|
||||
duration: State<Int>,
|
||||
brokenAudio: Boolean,
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
timedMessagesTTL: Int?,
|
||||
play: () -> Unit,
|
||||
pause: () -> Unit,
|
||||
longClick: () -> Unit
|
||||
) {
|
||||
when {
|
||||
hasText -> {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
DurationText(text, PaddingValues(start = 12.dp))
|
||||
}
|
||||
sent -> {
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.height(56.dp))
|
||||
DurationText(text, PaddingValues(end = 12.dp))
|
||||
}
|
||||
Column {
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DurationText(text, PaddingValues(start = 12.dp))
|
||||
Spacer(Modifier.height(56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DurationText(text: State<String>, padding: PaddingValues) {
|
||||
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
|
||||
Text(
|
||||
text.value,
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.widthIn(min = minWidth),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
@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.size(56.dp).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
|
||||
@@ -21,35 +20,41 @@ import androidx.compose.ui.unit.dp
|
||||
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.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||
|
||||
@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,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> 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) }
|
||||
val revealed = remember { mutableStateOf(false) }
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
@@ -62,7 +67,7 @@ fun ChatItemView(
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
@@ -70,87 +75,134 @@ fun ChatItemView(
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
|
||||
) {
|
||||
@Composable fun ContentItem() {
|
||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
@Composable
|
||||
fun framedItemView() {
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||
}
|
||||
|
||||
fun deleteMessageQuestionText(): String {
|
||||
return if (fullDeleteAllowed) {
|
||||
generalGetString(R.string.delete_message_cannot_be_undone_warning)
|
||||
} else {
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||
generalGetString(R.string.delete_message_mark_deleted_warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MsgContentItemDropdownMenu() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
if (!cItem.meta.itemDeleted) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
if (cItem.meta.itemDeleted && revealed.value) {
|
||||
ItemAction(
|
||||
stringResource(R.string.hide_verb),
|
||||
Icons.Outlined.VisibilityOff,
|
||||
onClick = {
|
||||
revealed.value = false
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun DeletedItem() {
|
||||
DeletedItemView(cItem, showMember = showMember)
|
||||
@Composable
|
||||
fun MarkedDeletedItemDropdownMenu() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.reveal_verb),
|
||||
Icons.Outlined.Visibility,
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
}
|
||||
)
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContentItem() {
|
||||
val mc = cItem.content.msgContent
|
||||
if (cItem.meta.itemDeleted && !revealed.value) {
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
MarkedDeletedItemDropdownMenu()
|
||||
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) {
|
||||
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem, cInfo.timedMessagesTTL)
|
||||
MsgContentItemDropdownMenu()
|
||||
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
|
||||
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
|
||||
MsgContentItemDropdownMenu()
|
||||
} else {
|
||||
framedItemView()
|
||||
MsgContentItemDropdownMenu()
|
||||
}
|
||||
} else {
|
||||
framedItemView()
|
||||
MsgContentItemDropdownMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun DeletedItem() {
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,18 +217,47 @@ fun ChatItemView(
|
||||
is CIContent.RcvDeleted -> DeletedItem()
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
||||
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.RcvChatPreference -> {
|
||||
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
|
||||
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
|
||||
}
|
||||
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, icon = c.feature.icon,)
|
||||
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
showMenu: MutableState<Boolean>,
|
||||
questionText: String,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
||||
DropdownMenuItem(onClick) {
|
||||
@@ -194,10 +275,10 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(R.string.delete_message__question),
|
||||
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
|
||||
text = questionText,
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier
|
||||
@@ -233,20 +314,19 @@ private fun showMsgDeliveryErrorAlert(description: String) {
|
||||
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 = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -256,18 +336,17 @@ 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 = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
@@ -35,7 +35,7 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,8 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun PreviewDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||
|
||||
@Composable
|
||||
fun EmojiItemView(chatItem: ChatItem) {
|
||||
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(chatItem.content.text)
|
||||
CIMetaView(chatItem)
|
||||
CIMetaView(chatItem, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ 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.material.icons.outlined.Delete
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -13,18 +14,19 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.util.fastMap
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ChatItemLinkView
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
val SentColorLight = Color(0x1E45B8FF)
|
||||
@@ -39,12 +41,14 @@ fun FramedItemView(
|
||||
uriHandler: UriHandler? = null,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
linkMode: SimplexLinkMode,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
scrollToItem: (Long) -> Unit = {},
|
||||
) {
|
||||
val sent = ci.chatDir.sent
|
||||
val chatTTL = chatInfo.timedMessagesTTL
|
||||
|
||||
fun membership(): GroupMember? {
|
||||
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
||||
@@ -58,7 +62,39 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) {
|
||||
Row(
|
||||
Modifier
|
||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.padding(end = 12.dp)
|
||||
.padding(top = 6.dp)
|
||||
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
caption,
|
||||
Modifier.size(18.dp),
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
}
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) {
|
||||
append(caption)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -87,13 +123,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 +141,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 != "" || ci.meta.isLive) {
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(
|
||||
@@ -119,8 +164,13 @@ fun FramedItemView(
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted) {
|
||||
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
|
||||
} else if (ci.meta.isLive) {
|
||||
FramedItemHeader(stringResource(R.string.live), false)
|
||||
}
|
||||
ci.quotedItem?.let { ciQuoteView(it) }
|
||||
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
|
||||
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -136,29 +186,36 @@ fun FramedItemView(
|
||||
when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||
if (mc.text == "") {
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, showMember, uriHandler)
|
||||
CIMarkdownText(ci, chatTTL, 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, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
||||
if (mc.text != "") {
|
||||
CIMarkdownText(ci, showMember, uriHandler)
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||
is MsgContent.MCUnknown ->
|
||||
if (ci.file == null) {
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
} else {
|
||||
ciFileView(ci, mc.text)
|
||||
}
|
||||
is MsgContent.MCLink -> {
|
||||
ChatItemLinkView(mc.preview)
|
||||
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
||||
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
CIMetaView(ci, chatTTL, metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,14 +224,17 @@ fun FramedItemView(
|
||||
@Composable
|
||||
fun CIMarkdownText(
|
||||
ci: ChatItem,
|
||||
chatTTL: Int?,
|
||||
showMember: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
uriHandler: UriHandler?,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
MarkdownText(
|
||||
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited,
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||
)
|
||||
}
|
||||
@@ -225,6 +285,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 +302,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 +323,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 +345,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 +367,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 +396,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
|
||||
quotedItem = ciQuote,
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -359,6 +425,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
|
||||
quotedItem = ciQuote,
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
@@ -386,6 +453,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
||||
quotedItem = ciQuote,
|
||||
itemEdited = edited
|
||||
),
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
showMenu = showMenu,
|
||||
receiveFile = {}
|
||||
)
|
||||
|
||||
@@ -77,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
Surface(
|
||||
Modifier.clickable(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -45,7 +45,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,8 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun IntegrityErrorItemViewPreview() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewMarkedDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getSampleData(itemDeleted = true),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,30 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.util.Log
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.detectGesture
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||
@@ -36,40 +46,97 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
|
||||
}
|
||||
}
|
||||
|
||||
private val noTyping: AnnotatedString = AnnotatedString(" ")
|
||||
|
||||
private val typingIndicators: List<AnnotatedString> = listOf(
|
||||
typing(FontWeight.Black) + typing() + typing(),
|
||||
typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(),
|
||||
typing() + typing(FontWeight.Bold) + typing(FontWeight.Black),
|
||||
typing() + typing() + typing(FontWeight.Bold)
|
||||
)
|
||||
|
||||
|
||||
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||
pushStyle(SpanStyle(color = HighOrLowlight, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
||||
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
||||
}
|
||||
|
||||
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||
AnnotatedString(".", SpanStyle(fontWeight = w))
|
||||
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: String,
|
||||
formattedText: List<FormattedText>? = null,
|
||||
sender: String? = null,
|
||||
metaText: String? = null,
|
||||
edited: Boolean = false,
|
||||
meta: CIMeta? = null,
|
||||
chatTTL: Int? = null,
|
||||
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
uriHandler: UriHandler? = null,
|
||||
senderBold: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
linkMode: SimplexLinkMode,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
}
|
||||
val reserve = when {
|
||||
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
|
||||
edited -> " "
|
||||
else -> " "
|
||||
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
||||
"\n"
|
||||
} else if (meta != null) {
|
||||
reserveSpaceForMeta(meta, chatTTL)
|
||||
} else {
|
||||
" "
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
else
|
||||
LocalLayoutDirection.current
|
||||
) {
|
||||
var timer: Job? by remember { mutableStateOf(null) }
|
||||
var typingIdx by rememberSaveable { mutableStateOf(0) }
|
||||
fun stopTyping() {
|
||||
timer?.cancel()
|
||||
timer = null
|
||||
}
|
||||
fun switchTyping() {
|
||||
if (meta != null && meta.isLive && meta.recent) {
|
||||
timer = timer ?: scope.launch {
|
||||
while (isActive) {
|
||||
typingIdx = (typingIdx + 1) % typingIndicators.size
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
if (meta?.isLive == true) {
|
||||
val activity = LocalContext.current as Activity
|
||||
LaunchedEffect(meta.recent, meta.isLive) {
|
||||
switchTyping()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
append(text)
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
} else {
|
||||
@@ -79,22 +146,29 @@ 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
|
||||
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
|
||||
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
|
||||
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
@@ -104,7 +178,15 @@ fun MarkdownText (
|
||||
},
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
.firstOrNull()?.let { annotation ->
|
||||
try {
|
||||
uriHandler.openUri(annotation.item)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
|
||||
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
|
||||
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
},
|
||||
shouldConsumeEvent = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||
import chat.simplex.app.views.usersettings.MarkdownHelpView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
|
||||
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||
@@ -76,6 +77,15 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
|
||||
MarkdownHelpView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
|
||||
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 = {},
|
||||
|
||||
@@ -22,12 +22,16 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.connectIfOpenedViaUri
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -41,9 +45,22 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
|
||||
else newChatSheetState.value = NewChatSheetState.GONE
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (shouldShowWhatsNew(chatModel)) {
|
||||
delay(1000L)
|
||||
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
|
||||
}
|
||||
LaunchedEffect(chatModel.appOpenUrl.value) {
|
||||
val url = chatModel.appOpenUrl.value
|
||||
if (url != null) {
|
||||
chatModel.appOpenUrl.value = null
|
||||
connectIfOpenedViaUri(url, chatModel)
|
||||
}
|
||||
}
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
Scaffold(
|
||||
|
||||
@@ -6,8 +6,7 @@ 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.Cancel
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -26,7 +25,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
|
||||
@@ -63,11 +62,21 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerifiedIcon() {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewTitle() {
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (cInfo.contact.verified) {
|
||||
VerifiedIcon()
|
||||
}
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||
@@ -83,11 +92,11 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
MarkdownText(
|
||||
ci.text,
|
||||
ci.formattedText,
|
||||
if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description),
|
||||
if (!ci.meta.itemDeleted) ci.formattedText else null,
|
||||
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
||||
linkMode = linkMode,
|
||||
senderBold = true,
|
||||
metaText = null,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
@@ -232,6 +241,6 @@ fun ChatStatusImage(chat: Chat) {
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ fun DatabaseView(
|
||||
chatArchiveTime,
|
||||
chatLastStart,
|
||||
chatDbDeleted.value,
|
||||
m.controller.appPrefs.privacyFullBackup,
|
||||
appFilesCountAndSize,
|
||||
chatItemTTL,
|
||||
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
||||
@@ -126,12 +127,13 @@ fun DatabaseLayout(
|
||||
chatDbChanged: Boolean,
|
||||
useKeyChain: Boolean,
|
||||
chatDbEncrypted: Boolean?,
|
||||
initialRandomDBPassphrase: Preference<Boolean>,
|
||||
initialRandomDBPassphrase: SharedPreference<Boolean>,
|
||||
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
chatLastStart: MutableState<Instant?>,
|
||||
chatDbDeleted: Boolean,
|
||||
privacyFullBackup: SharedPreference<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
chatItemTTL: MutableState<ChatItemTTL>,
|
||||
startChat: () -> Unit,
|
||||
@@ -165,6 +167,8 @@ fun DatabaseLayout(
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.IosShare,
|
||||
stringResource(R.string.export_database),
|
||||
@@ -409,7 +413,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,12 +687,13 @@ 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()) },
|
||||
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
||||
chatDbDeleted = false,
|
||||
privacyFullBackup = SharedPreference({ true }, {}),
|
||||
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
||||
startChat = {},
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.*
|
||||
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 alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
||||
@@ -35,6 +42,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,
|
||||
@@ -67,6 +94,41 @@ class AlertManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialogStacked(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
||||
@@ -102,4 +164,4 @@ class AlertManager {
|
||||
companion object {
|
||||
val shared = AlertManager()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,7 +30,7 @@ fun CloseSheetBar(close: () -> Unit) {
|
||||
@Composable
|
||||
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
||||
val padding = if (withPadding)
|
||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
|
||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
|
||||
else
|
||||
PaddingValues(bottom = DEFAULT_PADDING)
|
||||
Text(
|
||||
|
||||
@@ -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(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||
val interactionSource = remember { 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,
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
@@ -32,11 +33,9 @@ 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
|
||||
@@ -177,6 +176,25 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
|
||||
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
|
||||
|
||||
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
||||
try {
|
||||
launch(null)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// No Activity found to handle Intent android.media.action.IMAGE_CAPTURE
|
||||
// Means, no system camera app (Android 11+ requirement)
|
||||
// https://developer.android.com/about/versions/11/behavior-changes-11#media-capture
|
||||
Log.e(TAG, "Camera launcher: " + e.stackTraceToString())
|
||||
|
||||
try {
|
||||
// Try to open any camera just to capture an image, will not be returned like with previous intent
|
||||
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// No camera apps available at all
|
||||
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GetImageBottomSheet(
|
||||
imageBitmap: MutableState<Uri?>,
|
||||
@@ -204,7 +222,7 @@ fun GetImageBottomSheet(
|
||||
}
|
||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launch(null)
|
||||
cameraLauncher.launchWithFallback()
|
||||
hideBottomSheet()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
@@ -228,7 +246,7 @@ fun GetImageBottomSheet(
|
||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launch(null)
|
||||
cameraLauncher.launchWithFallback()
|
||||
hideBottomSheet()
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.*
|
||||
import android.media.AudioManager.AudioPlaybackCallback
|
||||
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
|
||||
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 chat.simplex.app.views.helpers.AudioPlayer.duration
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
interface Recorder {
|
||||
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
|
||||
fun stop(): Int
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var progressJob: Job? = null
|
||||
private var filePath: String? = null
|
||||
private var recStartedAt: Long? = null
|
||||
private fun initRecorder() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(SimplexApp.context)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
||||
AudioPlayer.stop()
|
||||
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(MAX_VOICE_MILLIS_FOR_SENDING)
|
||||
rec.setMaxFileSize(recordedBytesLimit)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
|
||||
filePath = path
|
||||
rec.setOutputFile(path)
|
||||
rec.prepare()
|
||||
rec.start()
|
||||
recStartedAt = System.currentTimeMillis()
|
||||
progressJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
while(isActive) {
|
||||
onProgressUpdate(progress(), false)
|
||||
delay(50)
|
||||
}
|
||||
}.apply {
|
||||
invokeOnCompletion {
|
||||
onProgressUpdate(realDuration(path), true)
|
||||
}
|
||||
}
|
||||
rec.setOnInfoListener { _, what, _ ->
|
||||
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED || what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
stopRecording = { stop() }
|
||||
return path
|
||||
}
|
||||
|
||||
override fun stop(): Int {
|
||||
val path = filePath ?: return 0
|
||||
stopRecording = null
|
||||
runCatching {
|
||||
recorder?.stop()
|
||||
}
|
||||
runCatching {
|
||||
recorder?.reset()
|
||||
}
|
||||
runCatching {
|
||||
recorder?.release()
|
||||
}
|
||||
// Await coroutine finishes in order to send real duration to it's listener
|
||||
runBlocking {
|
||||
progressJob?.cancelAndJoin()
|
||||
}
|
||||
progressJob = null
|
||||
filePath = null
|
||||
recorder = null
|
||||
return (realDuration(path) ?: 0).also { recStartedAt = null }
|
||||
}
|
||||
|
||||
private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() }
|
||||
|
||||
/**
|
||||
* Return real duration from [AudioPlayer] if it's possible (should always be possible).
|
||||
* As a fallback, return internally counted duration
|
||||
* */
|
||||
private fun realDuration(path: String): Int? = duration(path) ?: progress()
|
||||
}
|
||||
|
||||
object AudioPlayer {
|
||||
private val player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build()
|
||||
)
|
||||
(SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||
.registerAudioPlaybackCallback(object: AudioPlaybackCallback() {
|
||||
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
|
||||
if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) {
|
||||
// In a process of making a call
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
stop()
|
||||
}
|
||||
super.onPlaybackConfigChanged(configs)
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
// Filepath: String, onProgressUpdate
|
||||
private val currentlyPlaying: MutableState<Pair<String, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
|
||||
enum class TrackState {
|
||||
PLAYING, PAUSED, REPLACED
|
||||
}
|
||||
|
||||
// Returns real duration of the track
|
||||
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> 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, TrackState.PLAYING)
|
||||
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, TrackState.PLAYING)
|
||||
break
|
||||
}
|
||||
delay(50)
|
||||
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||
}
|
||||
/*
|
||||
* 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, TrackState.PAUSED)
|
||||
}
|
||||
onProgressUpdate(null, TrackState.PAUSED)
|
||||
}
|
||||
return player.duration
|
||||
}
|
||||
|
||||
private fun pause(): Int {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
player.pause()
|
||||
return player.currentPosition
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (currentlyPlaying.value == null) 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() {
|
||||
val afterCoroutineCancel: CompletionHandler = {
|
||||
// Notify prev audio listener about stop
|
||||
currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED)
|
||||
currentlyPlaying.value = null
|
||||
}
|
||||
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
|
||||
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
|
||||
* */
|
||||
if (progressJob != null) {
|
||||
progressJob?.invokeOnCompletion(afterCoroutineCancel)
|
||||
} else {
|
||||
afterCoroutineCancel(null)
|
||||
}
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
fun play(
|
||||
filePath: String?,
|
||||
audioPlaying: MutableState<Boolean>,
|
||||
progress: MutableState<Int>,
|
||||
duration: MutableState<Int>,
|
||||
resetOnEnd: Boolean,
|
||||
) {
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if (pro == null || pro == duration.value) {
|
||||
audioPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
} else if (state == TrackState.REPLACED) {
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
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: Int? = null
|
||||
kotlin.runCatching {
|
||||
helperPlayer.setDataSource(filePath)
|
||||
helperPlayer.prepare()
|
||||
helperPlayer.start()
|
||||
helperPlayer.stop()
|
||||
res = helperPlayer.duration
|
||||
helperPlayer.reset()
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -158,9 +186,13 @@ fun SectionSpacer() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoRow(title: String, value: String) {
|
||||
fun InfoRow(title: String, value: String, icon: ImageVector? = null, iconTint: Color? = null) {
|
||||
SectionItemViewSpaceBetween {
|
||||
Text(title)
|
||||
Row {
|
||||
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: HighOrLowlight)
|
||||
Text(title)
|
||||
}
|
||||
Text(value, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ fun SimpleButton(text: String, icon: ImageVector,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButton(
|
||||
text: String, icon: ImageVector,
|
||||
color: Color = MaterialTheme.colors.primary,
|
||||
disabled: Boolean,
|
||||
click: () -> Unit
|
||||
) {
|
||||
SimpleButtonFrame(click, disabled = disabled) {
|
||||
Icon(
|
||||
icon, text, tint = if (disabled) HighOrLowlight else color,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.caption, color = if (disabled) HighOrLowlight else color)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButtonIconEnded(
|
||||
text: String,
|
||||
|
||||
@@ -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.*
|
||||
@@ -19,6 +17,7 @@ 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.*
|
||||
@@ -30,7 +29,10 @@ import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.json
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -41,6 +43,9 @@ fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalSc
|
||||
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
|
||||
scope.launch { withContext(Dispatchers.Main, action) }
|
||||
|
||||
fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
|
||||
CoroutineScope(Dispatchers.Default).launch(block = action)
|
||||
|
||||
enum class KeyboardState {
|
||||
Opened, Closed
|
||||
}
|
||||
@@ -220,6 +225,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: Int = 43_000
|
||||
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
@@ -449,3 +459,15 @@ fun Color.darker(factor: Float = 0.1f): Color =
|
||||
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) }
|
||||
)
|
||||
|
||||
/* Make sure that T class has @Serializable annotation */
|
||||
inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
||||
save = { json.encodeToString(it) },
|
||||
restore = { json.decodeFromString(it) }
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
close.invoke()
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
AddGroupMembersView(groupInfo, true, chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
|
||||
enum class OnboardingStage {
|
||||
Step1_SimpleXInfo,
|
||||
Step2_CreateProfile,
|
||||
Step3_SetNotificationsMode,
|
||||
OnboardingComplete
|
||||
}
|
||||
|
||||
@@ -34,6 +35,9 @@ fun CreateProfile(chatModel: ChatModel) {
|
||||
.padding(20.dp)
|
||||
) {
|
||||
CreateProfilePanel(chatModel)
|
||||
LaunchedEffect(Unit) {
|
||||
setLastVersionDefault(chatModel)
|
||||
}
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.*
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import chat.simplex.app.views.usersettings.changeNotificationsMode
|
||||
|
||||
@Composable
|
||||
fun SetNotificationsMode(m: ChatModel) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.onboarding_notifications_mode_title), false)
|
||||
val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) }
|
||||
Text(stringResource(R.string.onboarding_notifications_mode_subtitle))
|
||||
Spacer(Modifier.padding(DEFAULT_PADDING_HALF))
|
||||
NotificationButton(currentMode, NotificationsMode.OFF, R.string.onboarding_notifications_mode_off, R.string.onboarding_notifications_mode_off_desc)
|
||||
NotificationButton(currentMode, NotificationsMode.PERIODIC, R.string.onboarding_notifications_mode_periodic, R.string.onboarding_notifications_mode_periodic_desc)
|
||||
NotificationButton(currentMode, NotificationsMode.SERVICE, R.string.onboarding_notifications_mode_service, R.string.onboarding_notifications_mode_service_desc)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage) {
|
||||
changeNotificationsMode(currentMode.value, m)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mode: NotificationsMode, @StringRes title: Int, @StringRes description: Int) {
|
||||
TextButton(
|
||||
onClick = { currentMode.value = mode },
|
||||
border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight.copy(alpha = 0.5f)),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 6.dp).padding(horizontal = 8.dp)) {
|
||||
Text(
|
||||
stringResource(title),
|
||||
style = MaterialTheme.typography.h2,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Text(annotatedStringResource(description), color = MaterialTheme.colors.onBackground, lineHeight = 24.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
|
||||
@@ -105,14 +104,14 @@ private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: I
|
||||
@Composable
|
||||
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
|
||||
if (user == null) {
|
||||
ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
|
||||
OnboardingActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
|
||||
} else {
|
||||
ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
|
||||
OnboardingActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
fun OnboardingActionButton(
|
||||
@StringRes labelId: Int,
|
||||
onboarding: OnboardingStage?,
|
||||
onboardingStage: MutableState<OnboardingStage?>,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
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.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
|
||||
|
||||
@Composable
|
||||
fun featureDescription(icon: ImageVector, titleId: Int, descrId: Int, link: String?) {
|
||||
@Composable
|
||||
fun linkButton(link: String) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, stringResource(titleId), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable { uriHandler.openUri(link) }
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(icon, stringResource(titleId), tint = HighOrLowlight)
|
||||
Text(
|
||||
generalGetString(titleId),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (link != null) {
|
||||
linkButton(link)
|
||||
}
|
||||
}
|
||||
Text(generalGetString(descrId))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun pagination() {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
if (currentVersion.value > 0) {
|
||||
val prev = currentVersion.value - 1
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.clickable { currentVersion.value = prev }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Outlined.ArrowBackIosNew, "previous", tint = MaterialTheme.colors.primary)
|
||||
Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
if (currentVersion.value < versionDescriptions.lastIndex) {
|
||||
val next = currentVersion.value + 1
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.clickable { currentVersion.value = next }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, "next", tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val v = versionDescriptions[currentVersion.value]
|
||||
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
String.format(generalGetString(R.string.new_in_version), v.version),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(DEFAULT_PADDING),
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
|
||||
v.features.forEach { feature ->
|
||||
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link)
|
||||
}
|
||||
|
||||
if (!viaSettings) {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(
|
||||
Modifier.fillMaxWidth(), contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.ok),
|
||||
modifier = Modifier.clickable(onClick = close),
|
||||
style = MaterialTheme.typography.h3,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
pagination()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FeatureDescription(
|
||||
val icon: ImageVector,
|
||||
val titleId: Int,
|
||||
val descrId: Int,
|
||||
val link: String? = null
|
||||
)
|
||||
|
||||
private data class VersionDescription(
|
||||
val version: String,
|
||||
val features: List<FeatureDescription>
|
||||
)
|
||||
|
||||
private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
VersionDescription(
|
||||
version = "v4.2",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VerifiedUser,
|
||||
titleId = R.string.v4_2_security_assessment,
|
||||
descrId = R.string.v4_2_security_assessment_desc,
|
||||
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Group,
|
||||
titleId = R.string.v4_2_group_links,
|
||||
descrId = R.string.v4_2_group_links_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Check,
|
||||
titleId = R.string.v4_2_auto_accept_contact_requests,
|
||||
descrId = R.string.v4_2_auto_accept_contact_requests_desc
|
||||
),
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.3",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Mic,
|
||||
titleId = R.string.v4_3_voice_messages,
|
||||
descrId = R.string.v4_3_voice_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.DeleteForever,
|
||||
titleId = R.string.v4_3_irreversible_message_deletion,
|
||||
descrId = R.string.v4_3_irreversible_message_deletion_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.WifiTethering,
|
||||
titleId = R.string.v4_3_improved_server_configuration,
|
||||
descrId = R.string.v4_3_improved_server_configuration_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VisibilityOff,
|
||||
titleId = R.string.v4_3_improved_privacy_and_security,
|
||||
descrId = R.string.v4_3_improved_privacy_and_security_desc
|
||||
),
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.4",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Timer,
|
||||
titleId = R.string.v4_4_disappearing_messages,
|
||||
descrId = R.string.v4_4_disappearing_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Pending,
|
||||
titleId = R.string.v4_4_live_messages,
|
||||
descrId = R.string.v4_4_live_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VerifiedUser,
|
||||
titleId = R.string.v4_4_verify_connection_security,
|
||||
descrId = R.string.v4_4_verify_connection_security_desc
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
private val lastVersion = versionDescriptions.last().version
|
||||
|
||||
fun setLastVersionDefault(m: ChatModel) {
|
||||
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
|
||||
}
|
||||
|
||||
fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
||||
val v = m.controller.appPrefs.whatsNewVersion.get()
|
||||
setLastVersionDefault(m)
|
||||
return v != lastVersion
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewWhatsNewView() {
|
||||
SimpleXTheme {
|
||||
WhatsNewView(
|
||||
viaSettings = true,
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,9 +79,10 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
|
||||
@Composable
|
||||
fun SharedPreferenceToggle(
|
||||
text: String,
|
||||
preference: Preference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null
|
||||
) {
|
||||
preference: SharedPreference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null,
|
||||
onChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 24.dp))
|
||||
@@ -91,6 +92,7 @@ fun SharedPreferenceToggle(
|
||||
onCheckedChange = {
|
||||
preference.set(it)
|
||||
prefState.value = it
|
||||
onChange?.invoke(it)
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
@@ -106,7 +108,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 +137,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,11 @@ package chat.simplex.app.views.usersettings
|
||||
import android.annotation.SuppressLint
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Log
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
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,12 +15,25 @@ 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)
|
||||
return String(cipher.doFinal(data))
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull()
|
||||
}
|
||||
|
||||
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
|
||||
@@ -29,7 +47,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 +59,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 {
|
||||
|
||||
@@ -17,18 +17,13 @@ import chat.simplex.app.model.Format
|
||||
import chat.simplex.app.model.FormatColor
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun MarkdownHelpView() {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.how_to_use_markdown), false)
|
||||
Text(stringResource(R.string.you_can_use_markdown_to_format_messages__prompt))
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
val bold = stringResource(R.string.bold)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,25 +46,6 @@ enum class NotificationPreviewMode {
|
||||
fun NotificationsSettingsView(
|
||||
chatModel: ChatModel,
|
||||
) {
|
||||
val onNotificationsModeSelected = { mode: NotificationsMode ->
|
||||
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
|
||||
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
chatModel.notificationsMode.value = mode
|
||||
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
if (mode == NotificationsMode.SERVICE)
|
||||
SimplexService.start(SimplexApp.context)
|
||||
else
|
||||
SimplexService.stop(SimplexApp.context)
|
||||
}
|
||||
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode ->
|
||||
chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name)
|
||||
chatModel.notificationPreviewMode.value = mode
|
||||
@@ -76,7 +57,7 @@ fun NotificationsSettingsView(
|
||||
showPage = { page ->
|
||||
ModalManager.shared.showModalCloseable(true) {
|
||||
when (page) {
|
||||
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected)
|
||||
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode) { changeNotificationsMode(it, chatModel) }
|
||||
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)
|
||||
}
|
||||
}
|
||||
@@ -159,7 +140,7 @@ fun NotificationPreviewView(
|
||||
}
|
||||
|
||||
// mode, name, description
|
||||
fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
|
||||
private fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
|
||||
val res = ArrayList<ValueTitleDesc<NotificationsMode>>()
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
@@ -211,3 +192,23 @@ fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
|
||||
)
|
||||
return res
|
||||
}
|
||||
|
||||
fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) {
|
||||
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
|
||||
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
chatModel.notificationsMode.value = mode
|
||||
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
if (mode == NotificationsMode.SERVICE)
|
||||
SimplexService.start(SimplexApp.context)
|
||||
else
|
||||
SimplexService.safeStopService(SimplexApp.context)
|
||||
}
|
||||
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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.runtime.saveable.rememberSaveable
|
||||
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, close: () -> Unit,) {
|
||||
var preferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(user.fullPreferences) }
|
||||
var currentPreferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
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
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (preferences == currentPreferences) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
PreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
applyPrefs = { preferences = it },
|
||||
reset = { preferences = currentPreferences },
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.allow) }
|
||||
TimedMessagesFeatureSection(timedMessages) {
|
||||
applyPrefs(preferences.copy(timedMessages = TimedMessagesPreference(allow = if (it) FeatureAllowed.YES else FeatureAllowed.NO)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
|
||||
FeatureSection(ChatFeature.FullDelete, allowFullDeletion) {
|
||||
applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
|
||||
FeatureSection(ChatFeature.Voice, allowVoice) {
|
||||
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
reset = reset,
|
||||
save = savePrefs,
|
||||
disabled = preferences == currentPreferences
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
feature.text,
|
||||
FeatureAllowed.values().map { it to it.text },
|
||||
allowFeature,
|
||||
icon = feature.icon,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(feature.allowDescription(allowFeature.value))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimedMessagesFeatureSection(allowFeature: State<FeatureAllowed>, onSelected: (Boolean) -> Unit) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
PreferenceToggleWithIcon(
|
||||
ChatFeature.TimedMessages.text,
|
||||
ChatFeature.TimedMessages.icon,
|
||||
HighOrLowlight,
|
||||
allowFeature.value == FeatureAllowed.ALWAYS || allowFeature.value == FeatureAllowed.YES,
|
||||
onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(ChatFeature.TimedMessages.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_contacts),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,88 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import android.view.WindowManager
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
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()
|
||||
val context = LocalContext.current
|
||||
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on ->
|
||||
if (on) {
|
||||
(context as? FragmentActivity)?.window?.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
} else {
|
||||
(context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
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: String): String =
|
||||
parseServerAddress(srv)?.hostnames?.firstOrNull() ?: srv
|
||||
@@ -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,319 @@
|
||||
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 allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } }
|
||||
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)
|
||||
} ||
|
||||
allServersDisabled.value
|
||||
}
|
||||
}
|
||||
|
||||
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 = testing.value,
|
||||
servers = servers,
|
||||
serversUnchanged = serversUnchanged.value,
|
||||
saveDisabled = saveDisabled.value,
|
||||
allServersDisabled = allServersDisabled.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,
|
||||
allServersDisabled: 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()
|
||||
val testServersDisabled = testing || allServersDisabled
|
||||
SectionItemView(testServers, disabled = testServersDisabled) {
|
||||
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testServersDisabled) 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.server)] = 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.CreateLinkTab
|
||||
import chat.simplex.app.views.newchat.CreateLinkView
|
||||
import chat.simplex.app.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
@@ -80,8 +81,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,29 +117,31 @@ 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(showCustomModal)
|
||||
}
|
||||
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()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_help)) {
|
||||
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
SettingsActionItem(Icons.Outlined.Add, stringResource(R.string.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
SectionDivider()
|
||||
@@ -175,7 +178,7 @@ fun SettingsLayout(
|
||||
|
||||
@Composable
|
||||
fun SettingsIncognitoActionItem(
|
||||
incognitoPref: Preference<Boolean>,
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
incognito: MutableState<Boolean>,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
@@ -237,6 +240,20 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit))) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.chat_preferences),
|
||||
click = {
|
||||
withApi {
|
||||
showCustomModal { m, close ->
|
||||
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
|
||||
}()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -365,12 +382,18 @@ 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,
|
||||
onChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
SectionItemView {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
SharedPreferenceToggle(text, pref, prefState)
|
||||
SharedPreferenceToggle(text, pref, prefState, onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,7 +405,7 @@ fun SettingsPreferenceItemWithInfo(
|
||||
text: String,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
pref: Preference<Boolean>,
|
||||
pref: SharedPreference<Boolean>,
|
||||
prefState: MutableState<Boolean>? = null
|
||||
) {
|
||||
SectionItemView(onClickInfo) {
|
||||
@@ -395,20 +418,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(
|
||||
@@ -438,13 +483,13 @@ 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 = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {}},
|
||||
showCustomModal = { {} },
|
||||
showTerminal = {},
|
||||
// showVideoChatPrototype = {}
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
895
apps/android/app/src/main/res/values-fr/strings.xml
Normal file
895
apps/android/app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,895 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="thousand_abbreviation">k</string>
|
||||
<string name="connect_via_contact_link">Se connecter via le lien du contact \?</string>
|
||||
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Votre profil va être envoyé au contact qui vous a envoyé ce lien.</string>
|
||||
<string name="you_will_join_group">Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe.</string>
|
||||
<string name="connect_via_link_verb">Se connecter</string>
|
||||
<string name="connect_via_group_link">Se connecter via le lien du groupe \?</string>
|
||||
<string name="connect_via_invitation_link">Se connecter via un lien d\'invitation \?</string>
|
||||
<string name="server_error">erreur</string>
|
||||
<string name="server_connecting">connexion</string>
|
||||
<string name="server_connected">connecté</string>
|
||||
<string name="display_name_connection_established">connexion établie</string>
|
||||
<string name="display_name_invited_to_connect">invité à se connecter</string>
|
||||
<string name="simplex_link_invitation">Invitation unique SimpleX</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">Liens SimpleX</string>
|
||||
<string name="simplex_link_mode_description">Description</string>
|
||||
<string name="error_deleting_contact">Erreur lors de la suppression du contact</string>
|
||||
<string name="error_joining_group">Erreur lors de la liaison avec le groupe</string>
|
||||
<string name="sender_cancelled_file_transfer">L\'expéditeur a annulé le transfert de fichiers.</string>
|
||||
<string name="deleted_description">supprimé</string>
|
||||
<string name="marked_deleted_description">marquer comme supprimé</string>
|
||||
<string name="unknown_message_format">format de message inconnu</string>
|
||||
<string name="display_name_connecting">connexion…</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">vous avez partagé un lien unique en incognito</string>
|
||||
<string name="description_via_group_link">via le lien de groupe</string>
|
||||
<string name="description_via_contact_address_link">via le lien d\'adresse du contact</string>
|
||||
<string name="description_via_contact_address_link_incognito">mode incognito via le lien d\'adresse du contact</string>
|
||||
<string name="simplex_link_group">Lien de groupe SimpleX</string>
|
||||
<string name="simplex_link_mode_browser">Via navigateur</string>
|
||||
<string name="simplex_link_mode_browser_warning">Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge.</string>
|
||||
<string name="network_error_desc">Vérifiez votre connexion réseau avec <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> et réessayez.</string>
|
||||
<string name="error_receiving_file">Erreur lors de la réception du fichier</string>
|
||||
<string name="sender_may_have_deleted_the_connection_request">L\'expéditeur a peut-être supprimé la demande de connexion.</string>
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact.</string>
|
||||
<string name="sending_files_not_yet_supported">l\'envoi de fichiers n\'est pas encore supporté</string>
|
||||
<string name="sender_you_pronoun">vous</string>
|
||||
<string name="description_via_group_link_incognito">mode incognito via le lien de groupe</string>
|
||||
<string name="simplex_link_contact">Adresse de contact SimpleX</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact.</string>
|
||||
<string name="receiving_files_not_yet_supported">la réception de fichiers n\'est pas encore supportée</string>
|
||||
<string name="connection_local_display_name">connexion <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="description_you_shared_one_time_link">vous avez partagé un lien unique</string>
|
||||
<string name="description_via_one_time_link">via un lien unique</string>
|
||||
<string name="description_via_one_time_link_incognito">mode incognito via un lien unique</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs SMP sont au bon format, séparées par des lignes et ne sont pas dupliquées.</string>
|
||||
<string name="error_setting_network_config">Erreur lors de la mise à jour de la configuration réseau</string>
|
||||
<string name="error_creating_address">Erreur lors de la création de l\'adresse</string>
|
||||
<string name="contact_already_exists">Contact déjà existant</string>
|
||||
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre.</string>
|
||||
<string name="connection_error">Erreur de connexion</string>
|
||||
<string name="error_adding_members">Erreur lors de l\'ajout de membre·s</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
|
||||
<string name="invalid_message_format">format de message invalide</string>
|
||||
<string name="simplex_link_mode_full">Lien entier</string>
|
||||
<string name="error_saving_smp_servers">Erreur lors de la sauvegarde des serveurs SMP</string>
|
||||
<string name="cannot_receive_file">Impossible de recevoir le fichier</string>
|
||||
<string name="invalid_connection_link">Lien de connection invalide</string>
|
||||
<string name="connection_timeout">Délai de connexion</string>
|
||||
<string name="error_sending_message">Erreur lors de l\'envoi du message</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">Vous êtes déjà connecté à <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
|
||||
<string name="connection_error_auth">Erreur de connexion (AUTH)</string>
|
||||
<string name="connection_error_auth_desc">A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s\'agir d\'un bug - veuillez le signaler.
|
||||
\nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d\'une connexion réseau stable.</string>
|
||||
<string name="error_accepting_contact_request">Erreur de validation de la demande de contact</string>
|
||||
<string name="error_deleting_group">Erreur lors de la suppression du groupe</string>
|
||||
<string name="error_deleting_contact_request">Erreur lors de la suppression du contact</string>
|
||||
<string name="error_deleting_pending_contact_connection">Erreur lors de la suppression de la connexion en attente</string>
|
||||
<string name="error_changing_address">Erreur de changement d\'adresse</string>
|
||||
<string name="error_smp_test_failed_at_step">Échec du test à l\'étape %s.</string>
|
||||
<string name="error_smp_test_certificate">Il est possible que l\'empreinte du certificat dans l\'adresse du serveur soit incorrecte</string>
|
||||
<string name="smp_server_test_connect">Se connecter</string>
|
||||
<string name="smp_server_test_create_queue">Créer une file d\'attente</string>
|
||||
<string name="smp_server_test_secure_queue">File d\'attente sécurisée</string>
|
||||
<string name="smp_server_test_delete_queue">Supprimer la file d\'attente</string>
|
||||
<string name="smp_server_test_disconnect">Se déconnecter</string>
|
||||
<string name="icon_descr_instant_notifications">Notifications instantanées</string>
|
||||
<string name="service_notifications">Notifications instantanées !</string>
|
||||
<string name="service_notifications_disabled">Les notifications instantanées sont désactivées !</string>
|
||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Il peut être désactivé via les paramètres</b> - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.</string>
|
||||
<string name="turning_off_service_and_periodic">L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres.</string>
|
||||
<string name="periodic_notifications">Notifications périodiques</string>
|
||||
<string name="periodic_notifications_disabled">Les notifications périodiques sont désactivées !</string>
|
||||
<string name="enter_passphrase_notification_title">Une phrase secrète est nécessaire</string>
|
||||
<string name="turn_off_battery_optimization">Pour l\'utiliser, veuillez <b>désactiver l\'optimisation de la batterie</b> pour <xliff:g id="appName">SimpleX</xliff:g> dans la prochaine fenêtre de dialogue. Sinon, les notifications seront désactivées.</string>
|
||||
<string name="error_smp_test_server_auth">Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe</string>
|
||||
<string name="periodic_notifications_desc">L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs.</string>
|
||||
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Pour protéger votre vie privée, au lieu des notifications push, l\'application possède un <b><xliff:g id="appName">SimpleX</xliff:g> service de fond</b> - il utilise quelques pour cent de la batterie par jour.</string>
|
||||
<string name="hide_notification">Cacher</string>
|
||||
<string name="settings_notification_preview_mode_title">Montrer l\'aperçu</string>
|
||||
<string name="notification_preview_mode_contact">Nom du contact</string>
|
||||
<string name="notification_preview_somebody">Contact masqué :</string>
|
||||
<string name="notification_preview_new_message">nouveau message</string>
|
||||
<string name="notification_new_contact_request">Nouvelle demande de contact</string>
|
||||
<string name="notification_contact_connected">Connecté</string>
|
||||
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
|
||||
<string name="la_notice_turn_on">Activer</string>
|
||||
<string name="auth_simplex_lock_turned_on">SimpleX Lock activé</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l\'application après 30 secondes en arrière-plan.</string>
|
||||
<string name="auth_unlock">Déverrouiller</string>
|
||||
<string name="auth_enable_simplex_lock">Activer SimpleX Lock</string>
|
||||
<string name="auth_disable_simplex_lock">Désactiver SimpleX Lock</string>
|
||||
<string name="auth_unavailable">Authentification indisponible</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">L\'authentification de l\'appareil est désactivée. Désactivation de SimpleX Lock.</string>
|
||||
<string name="auth_open_chat_console">Ouvrir la console du chat</string>
|
||||
<string name="message_delivery_error_title">Erreur de distribution du message</string>
|
||||
<string name="message_delivery_error_desc">Il est fort probable que ce contact ait supprimé la connexion avec vous.</string>
|
||||
<string name="reply_verb">Répondre</string>
|
||||
<string name="share_verb">Partager</string>
|
||||
<string name="copy_verb">Copier</string>
|
||||
<string name="delete_verb">Supprimer</string>
|
||||
<string name="save_verb">Sauvegarder</string>
|
||||
<string name="edit_verb">Modifier</string>
|
||||
<string name="reveal_verb">Révéler</string>
|
||||
<string name="hide_verb">Cacher</string>
|
||||
<string name="allow_verb">Autoriser</string>
|
||||
<string name="delete_message__question">Supprimer le message \?</string>
|
||||
<string name="for_me_only">Supprimer pour moi</string>
|
||||
<string name="your_chats">Vos chats</string>
|
||||
<string name="notification_preview_mode_message">Texte du message</string>
|
||||
<string name="notification_preview_mode_hidden">Caché</string>
|
||||
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Pour protéger vos informations, activez la fonction SimpleX Lock.
|
||||
\nVous serez invité à confirmer l\'authentification avant que cette fonction ne soit activée.</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">L\'authentification de l\'appareil n\'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l\'authentification de l\'appareil.</string>
|
||||
<string name="database_initialization_error_desc">La base de données ne fonctionne pas correctement. Appuyez ici pour en savoir plus.</string>
|
||||
<string name="ntf_channel_calls">Appels SimpleX Chat</string>
|
||||
<string name="ntf_channel_messages">Messages SimpleX Chat</string>
|
||||
<string name="settings_notifications_mode_title">Service de notification</string>
|
||||
<string name="notifications_mode_periodic">Lancer périodiquement</string>
|
||||
<string name="notifications_mode_off">Exécuter lorsque l’app est ouverte</string>
|
||||
<string name="notifications_mode_service">Toujours activé</string>
|
||||
<string name="failed_to_parse_chat_title">Échec du chargement du chat</string>
|
||||
<string name="failed_to_parse_chats_title">Échec du chargement des chats</string>
|
||||
<string name="contact_developers">Veuillez mettre à jour l’app et contacter les développeurs.</string>
|
||||
<string name="simplex_service_notification_text">Récupération des messages…</string>
|
||||
<string name="settings_notification_preview_title">Aperçu de notification</string>
|
||||
<string name="database_initialization_error_title">Échec d’initialisation de la base de données</string>
|
||||
<string name="enter_passphrase_notification_desc">Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données</string>
|
||||
<string name="simplex_service_notification_title">service <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||
<string name="this_text_is_available_in_settings">Ce texte est disponible dans les paramètres</string>
|
||||
<string name="group_preview_join_as">rejoindre en tant que %s</string>
|
||||
<string name="group_preview_you_are_invited">vous êtes invité·e au groupe</string>
|
||||
<string name="chat_with_developers">Discuter avec les développeurs</string>
|
||||
<string name="tap_to_start_new_chat">Appuyez pour commencer un nouveau chat</string>
|
||||
<string name="you_have_no_chats">Vous n\'avez aucune discussion</string>
|
||||
<string name="images_limit_title">Trop d’images !</string>
|
||||
<string name="share_file">Partager le fichier…</string>
|
||||
<string name="attach">Attacher</string>
|
||||
<string name="icon_descr_cancel_image_preview">Annuler l’aperçu d’image</string>
|
||||
<string name="icon_descr_cancel_file_preview">Annuler l’aperçu du fichier</string>
|
||||
<string name="icon_descr_sent_msg_status_send_failed">échec d’envoi</string>
|
||||
<string name="icon_descr_received_msg_status_unread">non lu</string>
|
||||
<string name="welcome">Bienvenue !</string>
|
||||
<string name="contact_connection_pending">connexion…</string>
|
||||
<string name="group_connection_pending">connexion…</string>
|
||||
<string name="share_message">Partager le message…</string>
|
||||
<string name="share_image">Partager l’image…</string>
|
||||
<string name="images_limit_desc">Envoi de 10 images en même temps maximum</string>
|
||||
<string name="personal_welcome">Bienvenue <xliff:g>%1$s</xliff:g> !</string>
|
||||
<string name="notifications_mode_periodic_desc">Vérifie les nouveaux messages toutes les 10 minutes pendant 1 minute au maximum.</string>
|
||||
<string name="notification_preview_mode_contact_desc">Afficher uniquement le contact</string>
|
||||
<string name="for_everybody">Pour tous</string>
|
||||
<string name="icon_descr_sent_msg_status_sent">envoyé</string>
|
||||
<string name="icon_descr_sent_msg_status_unauthorized_send">envoi non autorisé</string>
|
||||
<string name="icon_descr_context">Icône contextuelle</string>
|
||||
<string name="image_descr">Image</string>
|
||||
<string name="image_decoding_exception_desc">L\'image ne peut pas être décodée. Veuillez essayer une autre image ou contacter les développeurs.</string>
|
||||
<string name="icon_descr_waiting_for_image">En attente de l\'image</string>
|
||||
<string name="icon_descr_asked_to_receive">Demandé à recevoir l\'image</string>
|
||||
<string name="icon_descr_image_snd_complete">Image envoyée</string>
|
||||
<string name="waiting_for_image">En attente de l\'image</string>
|
||||
<string name="image_saved">Image enregistrée dans la phototèque</string>
|
||||
<string name="icon_descr_file">Fichier</string>
|
||||
<string name="large_file">Fichier trop lourd !</string>
|
||||
<string name="file_saved">Fichier sauvegardé</string>
|
||||
<string name="file_not_found">Fichier introuvable</string>
|
||||
<string name="error_saving_file">Erreur lors de la sauvegarde du fichier</string>
|
||||
<string name="delete_contact_question">Supprimer le contact \?</string>
|
||||
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Le contact et tous les messages seront supprimés - impossible de revenir en arrière !</string>
|
||||
<string name="button_delete_contact">Supprimer le contact</string>
|
||||
<string name="icon_descr_server_status_connected">Connecté</string>
|
||||
<string name="icon_descr_send_message">Envoyer un message</string>
|
||||
<string name="switch_receiving_address_question">Changement d\'adresse de réception \?</string>
|
||||
<string name="icon_descr_record_voice_message">Enregistrer un message vocal</string>
|
||||
<string name="allow_voice_messages_question">Autoriser les messages vocaux \?</string>
|
||||
<string name="you_need_to_allow_to_send_voice">Vous devez autoriser votre contact à envoyer des messages vocaux pour pouvoir en envoyer.</string>
|
||||
<string name="voice_messages_prohibited">Messages vocaux interdits !</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Veuillez demander à votre contact de permettre l\'envoi de messages vocaux.</string>
|
||||
<string name="cancel_verb">Annuler</string>
|
||||
<string name="only_group_owners_can_enable_voice">Seuls les propriétaires de groupes peuvent activer les messages vocaux.</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="no_details">aucun détail</string>
|
||||
<string name="add_contact">Lien d\'invitation unique</string>
|
||||
<string name="copied">Copié dans le presse-papiers</string>
|
||||
<string name="share_one_time_link">Créer un lien d\'invitation unique</string>
|
||||
<string name="add_contact_or_create_group">Commencer une nouvelle discussion</string>
|
||||
<string name="connect_via_link_or_qr">Se connecter via un lien / code QR</string>
|
||||
<string name="scan_QR_code">Scanner un code QR</string>
|
||||
<string name="to_share_with_your_contact">(à partager avec votre contact)</string>
|
||||
<string name="create_group">Créer un groupe secret</string>
|
||||
<string name="from_gallery_button">Depuis la Phototèque</string>
|
||||
<string name="choose_file">Choisir le fichier</string>
|
||||
<string name="to_start_a_new_chat_help_header">Pour démarrer une nouvelle discussion</string>
|
||||
<string name="chat_help_tap_button">Appuyez sur le bouton</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scanner un code QR</b> : pour vous connecter à votre contact qui vous montre un code QR.</string>
|
||||
<string name="to_connect_via_link_title">Pour se connecter via un lien</string>
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Si vous avez reçu un lien d\'invitation <xliff:g id="appName">SimpleX Chat</xliff:g>, vous pouvez l\'ouvrir dans votre navigateur :</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 bureau : scanner le code QR affiché depuis l\'app, via <b>Scanner le code QR</b>.</string>
|
||||
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile : appuyez sur <b>Ouvrir dans l\'application</b>, puis appuyez sur <b>se connecter</b> dans l\'app.</string>
|
||||
<string name="accept_contact_incognito_button">Accepter en incognito</string>
|
||||
<string name="reject_contact_button">Rejeter</string>
|
||||
<string name="clear_chat_question">Effacer la conversation \?</string>
|
||||
<string name="clear_chat_warning">Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous.</string>
|
||||
<string name="clear_chat_menu_action">Effacer</string>
|
||||
<string name="delete_contact_menu_action">Supprimer</string>
|
||||
<string name="delete_group_menu_action">Supprimer</string>
|
||||
<string name="mark_read">Marquer comme lu</string>
|
||||
<string name="mark_unread">Marquer non lu</string>
|
||||
<string name="set_contact_name">Définir le nom du contact</string>
|
||||
<string name="you_invited_your_contact">Vous avez invité votre contact</string>
|
||||
<string name="you_accepted_connection">Vous avez accepté la connexion</string>
|
||||
<string name="delete_pending_connection__question">Supprimer la connexion en attente \?</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">La connexion que vous avez acceptée sera annulée !</string>
|
||||
<string name="alert_title_contact_connection_pending">Le contact n\'est pas encore connecté !</string>
|
||||
<string name="icon_descr_close_button">Bouton fermer</string>
|
||||
<string name="image_descr_profile_image">image de profil</string>
|
||||
<string name="image_descr_link_preview">image d\'aperçu du lien</string>
|
||||
<string name="icon_descr_cancel_link_preview">annuler l\'aperçu du lien</string>
|
||||
<string name="icon_descr_settings">Paramètres</string>
|
||||
<string name="icon_descr_address">Adresse <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="icon_descr_help">aide</string>
|
||||
<string name="icon_descr_simplex_team">Équipe <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="icon_descr_more_button">Plus</string>
|
||||
<string name="show_QR_code">Afficher le code QR</string>
|
||||
<string name="invalid_QR_code">Code QR invalide</string>
|
||||
<string name="this_QR_code_is_not_a_link">Ce code QR n\'est pas un lien !</string>
|
||||
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard !</string>
|
||||
<string name="you_will_be_connected_when_your_contacts_device_is_online">Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard !</string>
|
||||
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Votre contact peut scanner le code QR depuis l\'app.</string>
|
||||
<string name="your_chat_profile_will_be_sent_to_your_contact">Votre profil de chat sera envoyé
|
||||
\nà votre contact</string>
|
||||
<string name="share_invitation_link">Partager le lien d\'invitation</string>
|
||||
<string name="your_profile_will_be_sent">Votre profil de chat sera envoyé à votre contact</string>
|
||||
<string name="paste_button">Coller</string>
|
||||
<string name="this_string_is_not_a_connection_link">Cette chaîne n\'est pas un lien de connexion !</string>
|
||||
<string name="you_can_also_connect_by_clicking_the_link">Vous pouvez aussi vous connecter en cliquant sur le lien. Si il s\'ouvre dans le navigateur, cliquez sur <b>Ouvrir dans l\'app mobile</b>.</string>
|
||||
<string name="create_one_time_link">Créer un lien d\'invitation unique</string>
|
||||
<string name="text_field_set_contact_placeholder">Définir le nom du contact…</string>
|
||||
<string name="icon_descr_server_status_disconnected">Déconnecté</string>
|
||||
<string name="icon_descr_server_status_error">Erreur</string>
|
||||
<string name="icon_descr_server_status_pending">En attente</string>
|
||||
<string name="accept_connection_request__question">Accepter la demande de connexion \?</string>
|
||||
<string name="clear_verb">Effacer</string>
|
||||
<string name="clear_chat_button">Effacer la conversation</string>
|
||||
<string name="paste_connection_link_below_to_connect">Collez le lien que vous avez reçu dans le cadre ci-dessous pour vous connecter avec votre contact.</string>
|
||||
<string name="connect_via_link">Se connecter via un lien</string>
|
||||
<string name="clear_verification">Retirer la vérification</string>
|
||||
<string name="one_time_link">Lien d\'invitation unique</string>
|
||||
<string name="your_contact_address">Votre adresse de contact</string>
|
||||
<string name="scan_code">Scanner le code</string>
|
||||
<string name="incorrect_code">Code de sécurité incorrect !</string>
|
||||
<string name="security_code">Code de sécurité</string>
|
||||
<string name="mark_code_verified">Marquer comme vérifié</string>
|
||||
<string name="view_security_code">Afficher le code de sécurité</string>
|
||||
<string name="verify_security_code">Vérifier le code de sécurité</string>
|
||||
<string name="confirm_verb">Confirmer</string>
|
||||
<string name="reset_verb">Réinitialisation</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(scanner ou coller depuis le presse-papiers)</string>
|
||||
<string name="only_stored_on_members_devices">(uniquement stocké par les membres du groupe)</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Votre contact a besoin d\'être en ligne pour completer la connexion.
|
||||
\nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien).</string>
|
||||
<string name="contact_wants_to_connect_with_you">veut établir une connexion !</string>
|
||||
<string name="icon_descr_profile_image_placeholder">image de profil (placeholder)</string>
|
||||
<string name="image_descr_qr_code">Code QR</string>
|
||||
<string name="image_descr_simplex_logo">Logo <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="icon_descr_email">E-mail</string>
|
||||
<string name="connect_button">Se connecter</string>
|
||||
<string name="notifications_mode_off_desc">L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé.</string>
|
||||
<string name="notifications_mode_service_desc">Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles.</string>
|
||||
<string name="notification_preview_mode_message_desc">Afficher le contact et le message</string>
|
||||
<string name="notification_display_mode_hidden_desc">Masquer le contact et le message</string>
|
||||
<string name="auth_log_in_using_credential">Connectez-vous en utilisant votre identifiant</string>
|
||||
<string name="auth_confirm_credential">Confirmez vos identifiants</string>
|
||||
<string name="auth_stop_chat">Arrêter le chat</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">Le message sera supprimé - impossible de revenir en arrière !</string>
|
||||
<string name="delete_message_mark_deleted_warning">Le message sera marqué comme supprimé. Le·s destinataire·s pourrai·ent révéler ce message.</string>
|
||||
<string name="icon_descr_edited">modifié</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">L\'image sera reçue quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard !</string>
|
||||
<string name="image_decoding_exception_title">Erreur de décodage</string>
|
||||
<string name="contact_sent_large_file">Votre contact a envoyé un fichier dont la taille est supérieure à la taille maximale actuellement prise en charge (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
|
||||
<string name="waiting_for_file">En attente du fichier</string>
|
||||
<string name="voice_message">Message vocal</string>
|
||||
<string name="toast_permission_denied">Autorisation refusée !</string>
|
||||
<string name="use_camera_button">Utiliser l\'Appareil photo</string>
|
||||
<string name="thank_you_for_installing_simplex">Merci d\'avoir installé <xliff:g id="appNameFull">SimpleX Chat</xliff:g> !</string>
|
||||
<string name="you_can_connect_to_simplex_chat_founder">Vous pouvez <font color="#0088ff">vous connecter aux développeurs de <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour leur poser toutes vos questions et pour recevoir des informations sur les mises à jour</font>.</string>
|
||||
<string name="above_then_preposition_continuation">ci-dessus, puis :</string>
|
||||
<string name="add_new_contact_to_create_one_time_QR_code"><b>Ajouter un nouveau contact</b> : afin de créer un code QR à usage unique pour votre contact.</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Si vous choisissez de la rejeter, l\'expéditeur·rice NE sera PAS notifié·e.</string>
|
||||
<string name="accept_contact_button">Accepter</string>
|
||||
<string name="mute_chat">Muet</string>
|
||||
<string name="unmute_chat">Démute</string>
|
||||
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter !</string>
|
||||
<string name="invalid_contact_link">Lien invalide !</string>
|
||||
<string name="this_link_is_not_a_valid_connection_link">Ce lien n\'est pas un lien de connexion valide !</string>
|
||||
<string name="connection_request_sent">Demande de connexion envoyée !</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard !</string>
|
||||
<string name="voice_message_send_text">Message vocal…</string>
|
||||
<string name="maximum_supported_file_size">La taille maximale supportés des fichiers actuellement est de <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
|
||||
<string name="voice_message_with_duration">Message vocal (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||
<string name="notifications">Notifications</string>
|
||||
<string name="switch_receiving_address_desc">Cette fonctionnalité est expérimentale ! Elle ne fonctionnera que si l\'autre client a la version 4.2 installée. Vous devriez voir le message dans la conversation une fois le changement d\'adresse effectué. Vérifiez que vous pouvez toujours recevoir des messages de ce contact (ou membre du groupe).</string>
|
||||
<string name="you_will_be_connected_when_group_host_device_is_online">Vous serez connecté·e au groupe lorsque l\'appareil de l\'hôte sera en ligne, veuillez attendre ou vérifier plus tard !</string>
|
||||
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Si vous ne pouvez pas vous rencontrer en personne, <b>montrez le code QR lors d\'un appel vidéo</b>, ou partagez le lien.</string>
|
||||
<string name="scan_code_from_contacts_app">Scannez le code de sécurité depuis l\'application de votre contact.</string>
|
||||
<string name="to_verify_compare">Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils.</string>
|
||||
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Si vous ne pouvez pas vous rencontrer en personne, vous pouvez <b>scanner un code QR lors d\'un appel vidéo</b>, ou votre contact peut partager un lien d\'invitation.</string>
|
||||
<string name="smp_servers_add">Ajouter un serveur…</string>
|
||||
<string name="markdown_in_messages">Markdown dans les messages</string>
|
||||
<string name="smp_servers_preset_add">Ajouter des serveurs prédéfinis</string>
|
||||
<string name="use_simplex_chat_servers__question">Utiliser les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g> \?</string>
|
||||
<string name="smp_servers_delete_server">Supprimer le serveur</string>
|
||||
<string name="ensure_ICE_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne.</string>
|
||||
<string name="network_enable_socks_info">Accéder aux serveurs via un proxy SOCKS sur le port 9050 \? Le proxy doit être démarré avant d\'activer cette option.</string>
|
||||
<string name="network_use_onion_hosts">Utiliser les hôtes .onions</string>
|
||||
<string name="network_use_onion_hosts_prefer_desc_in_alert">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
|
||||
<string name="network_use_onion_hosts_required_desc_in_alert">Les hôtes .onion seront nécessaires pour la connexion.</string>
|
||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Vous contrôlez par quel·s serveur·s vous pouvez <b>transmettre</b> ainsi que par quel·s serveur·s vous pouvez <b>recevoir</b> des messages de vos contacts.</string>
|
||||
<string name="your_settings">Vos paramètres</string>
|
||||
<string name="chat_lock">SimpleX Lock</string>
|
||||
<string name="chat_console">Console du chat</string>
|
||||
<string name="smp_servers">Serveurs SMP</string>
|
||||
<string name="smp_servers_test_servers">Tester les serveurs</string>
|
||||
<string name="smp_servers_save">Sauvegarder les serveurs</string>
|
||||
<string name="smp_servers_scan_qr">Scanner le code QR du serveur</string>
|
||||
<string name="smp_servers_use_server">Utiliser ce serveur</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">Utiliser pour les nouvelles connexions</string>
|
||||
<string name="smp_servers_add_to_another_device">Ajouter à un autre appareil</string>
|
||||
<string name="install_simplex_chat_for_terminal">Installer <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour terminal</string>
|
||||
<string name="star_on_github">Star sur GitHub</string>
|
||||
<string name="contribute">Contribuer</string>
|
||||
<string name="rate_the_app">Évaluer l\'app</string>
|
||||
<string name="your_SMP_servers">Vos serveurs SMP</string>
|
||||
<string name="how_to_use_your_servers">Comment utiliser vos serveurs</string>
|
||||
<string name="saved_ICE_servers_will_be_removed">Les serveurs WebRTC ICE sauvegardés seront supprimés.</string>
|
||||
<string name="your_ICE_servers">Vos serveurs ICE</string>
|
||||
<string name="configure_ICE_servers">Configurer les serveurs ICE</string>
|
||||
<string name="network_settings">Paramètres réseau avancés</string>
|
||||
<string name="network_settings_title">Paramètres réseau</string>
|
||||
<string name="network_socks_toggle">Utiliser un proxy SOCKS (port 9050)</string>
|
||||
<string name="network_enable_socks">Utiliser un proxy SOCKS \?</string>
|
||||
<string name="network_disable_socks">Utiliser une connexion Internet directe \?</string>
|
||||
<string name="network_disable_socks_info">Si vous confirmez, les serveurs de messagerie seront en mesure de voir votre adresse IP, votre fournisseur ainsi que les serveurs auxquels vous vous connectez.</string>
|
||||
<string name="network_use_onion_hosts_no">Non</string>
|
||||
<string name="network_use_onion_hosts_required">Requis</string>
|
||||
<string name="network_use_onion_hosts_prefer_desc">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
|
||||
<string name="appearance_settings">Apparence</string>
|
||||
<string name="create_address">Créer une adresse</string>
|
||||
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Vous pouvez partager votre adresse sous forme de lien ou de code QR - n\'importe qui pourra se connecter à vous. Vous ne perdrez pas vos contacts si vous les supprimez par la suite.</string>
|
||||
<string name="your_chat_profile">Votre profil de chat</string>
|
||||
<string name="edit_image">Modifier l\'image</string>
|
||||
<string name="save_and_notify_contacts">Sauvegarder et notifier les contacts</string>
|
||||
<string name="save_and_notify_group_members">Sauvegarder et en informer les membres du groupe</string>
|
||||
<string name="your_profile_is_stored_on_your_device">Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil.</string>
|
||||
<string name="profile_is_only_shared_with_your_contacts">Le profil n\'est partagé qu\'avec vos contacts.</string>
|
||||
<string name="display_name_cannot_contain_whitespace">Le nom d\'affichage ne peut pas contenir d\'espace.</string>
|
||||
<string name="full_name_optional__prompt">Nom complet (optionnel)</string>
|
||||
<string name="create_profile_button">Créer</string>
|
||||
<string name="about_simplex">À propos de SimpleX</string>
|
||||
<string name="you_can_use_markdown_to_format_messages__prompt">Vous pouvez utiliser le format markdown pour mettre en forme les messages :</string>
|
||||
<string name="bold">gras</string>
|
||||
<string name="italic">italique</string>
|
||||
<string name="strikethrough">barré</string>
|
||||
<string name="callstatus_accepted">appel accepté</string>
|
||||
<string name="callstatus_connecting">connexion à l\'appel…</string>
|
||||
<string name="callstatus_error">erreur d\'appel</string>
|
||||
<string name="callstate_received_answer">réponse reçu…</string>
|
||||
<string name="callstate_received_confirmation">confimation reçu…</string>
|
||||
<string name="callstate_connecting">connexion…</string>
|
||||
<string name="opensource_protocol_and_code_anybody_can_run_servers">Protocole et code open-source – tout le monde peut faire fonctionner les serveurs.</string>
|
||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Pour protéger la vie privée, au lieu d\'ID d\'utilisateur utilisés par toutes les autres plateformes, <xliff:g id="appName">SimpleX</xliff:g> possède des identifiants pour les files d\'attente de messages, distincts pour chacun de vos contacts.</string>
|
||||
<string name="read_more_in_github">Plus d\'informations sur notre GitHub.</string>
|
||||
<string name="paste_the_link_you_received">Coller le lien reçu</string>
|
||||
<string name="use_chat">Utiliser le chat</string>
|
||||
<string name="onboarding_notifications_mode_title">Notifications privées</string>
|
||||
<string name="onboarding_notifications_mode_subtitle">Peut être modifié ultérieurement via les paramètres.</string>
|
||||
<string name="onboarding_notifications_mode_off">Quand l\'application fonctionne</string>
|
||||
<string name="onboarding_notifications_mode_periodic">Périodique</string>
|
||||
<string name="onboarding_notifications_mode_service">Instantanée</string>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>Économie de batterie</b>. Vous recevrez des notifications uniquement lorsque l\'application est en cours d\'exécution, le service de fond ne sera PAS utilisé.</string>
|
||||
<string name="about_simplex_chat">À propos de <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||
<string name="how_to_use_simplex_chat">Comment l\'utiliser</string>
|
||||
<string name="markdown_help">Aide Markdown</string>
|
||||
<string name="save_servers_button">Sauvegarder</string>
|
||||
<string name="network_and_servers">Réseau et serveurs</string>
|
||||
<string name="save_and_notify_contact">Sauvegarder et en informer les contacts</string>
|
||||
<string name="exit_without_saving">Quitter sans sauvegarder</string>
|
||||
<string name="callstatus_rejected">appel rejeté</string>
|
||||
<string name="callstatus_in_progress">appel en cours</string>
|
||||
<string name="callstatus_ended">appel terminé <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstate_starting">lancement…</string>
|
||||
<string name="is_verified">%s est vérifié·e</string>
|
||||
<string name="is_not_verified">%s n\'est pas vérifié·e</string>
|
||||
<string name="your_simplex_contact_address">Votre adresse de contact <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="database_passphrase_and_export">Phrase secrète et exportation de la base de données</string>
|
||||
<string name="chat_with_the_founder">Envoyez vos questions et idées</string>
|
||||
<string name="send_us_an_email">Envoyez nous un e-mail</string>
|
||||
<string name="smp_servers_preset_address">Adresse du serveur prédéfinie</string>
|
||||
<string name="smp_servers_test_server">Tester le serveur</string>
|
||||
<string name="smp_servers_test_failed">Échec du test du serveur !</string>
|
||||
<string name="smp_servers_test_some_failed">Certains serveurs n\'ont pas réussi le test :</string>
|
||||
<string name="smp_servers_enter_manually">Entrer un serveur manuellement</string>
|
||||
<string name="smp_servers_preset_server">Serveur prédéfini</string>
|
||||
<string name="smp_servers_your_server">Votre serveur</string>
|
||||
<string name="smp_servers_your_server_address">Votre adresse de serveur</string>
|
||||
<string name="smp_servers_invalid_address">Adresse de serveur invalide !</string>
|
||||
<string name="smp_servers_check_address">Vérifiez l\'adresse du serveur et réessayez.</string>
|
||||
<string name="using_simplex_chat_servers">Utilise les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
|
||||
<string name="how_to">Comment faire</string>
|
||||
<string name="enter_one_ICE_server_per_line">Serveurs ICE (un par ligne)</string>
|
||||
<string name="error_saving_ICE_servers">Erreur lors de la sauvegarde des serveurs ICE</string>
|
||||
<string name="update_onion_hosts_settings_question">Mettre à jour le paramètre des hôtes .onion \?</string>
|
||||
<string name="network_use_onion_hosts_prefer">Quand disponible</string>
|
||||
<string name="network_use_onion_hosts_no_desc">Les hôtes .onion ne seront pas utilisés.</string>
|
||||
<string name="network_use_onion_hosts_required_desc">Les hôtes .onion seront nécessaires pour la connexion.</string>
|
||||
<string name="network_use_onion_hosts_no_desc_in_alert">Les hôtes .onion ne seront pas utilisés.</string>
|
||||
<string name="delete_address__question">Supprimer l\'adresse \?</string>
|
||||
<string name="all_your_contacts_will_remain_connected">Tous vos contacts resteront connectés.</string>
|
||||
<string name="share_link">Partager le lien</string>
|
||||
<string name="delete_address">Supprimer l\'adresse</string>
|
||||
<string name="contact_requests">Demandes de contact</string>
|
||||
<string name="accept_requests">Accepter les demandes</string>
|
||||
<string name="accept_automatically">Automatiquement</string>
|
||||
<string name="section_title_welcome_message">MESSAGE DE BIENVENUE</string>
|
||||
<string name="display_name__field">Nom affiché :</string>
|
||||
<string name="full_name__field">Nom complet :</string>
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts.
|
||||
\n
|
||||
\nLes serveurs <xliff:g id="appName">SimpleX</xliff:g> ne peuvent pas voir votre profil.</string>
|
||||
<string name="delete_image">Supprimer l\'image</string>
|
||||
<string name="save_preferences_question">Sauvegarder les préférences \?</string>
|
||||
<string name="you_control_your_chat">Vous maîtrisez vos discussions !</string>
|
||||
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité.</string>
|
||||
<string name="we_do_not_store_contacts_or_messages_on_servers">Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs.</string>
|
||||
<string name="create_profile">Créer le profil</string>
|
||||
<string name="display_name">Nom affiché</string>
|
||||
<string name="how_to_use_markdown">Comment utiliser markdown</string>
|
||||
<string name="a_plus_b">a + b</string>
|
||||
<string name="colored">coloré</string>
|
||||
<string name="secret">secret</string>
|
||||
<string name="callstatus_calling">appel…</string>
|
||||
<string name="callstatus_missed">appel manqué</string>
|
||||
<string name="callstate_waiting_for_answer">en attente de réponse…</string>
|
||||
<string name="callstate_waiting_for_confirmation">en attente de confirmation…</string>
|
||||
<string name="callstate_connected">connecté</string>
|
||||
<string name="callstate_ended">terminé</string>
|
||||
<string name="next_generation_of_private_messaging">La nouvelle génération de messagerie privée</string>
|
||||
<string name="privacy_redefined">La vie privée redéfinie</string>
|
||||
<string name="first_platform_without_user_ids">La 1ère plateforme sans aucun identifiant d\'utilisateur – privée par design.</string>
|
||||
<string name="immune_to_spam_and_abuse">Protégé du spam et des abus</string>
|
||||
<string name="people_can_connect_only_via_links_you_share">Les gens peuvent se connecter à vous uniquement via les liens que vous partagez.</string>
|
||||
<string name="decentralized">Décentralisé</string>
|
||||
<string name="create_your_profile">Créez votre profil</string>
|
||||
<string name="make_private_connection">Établir une connexion privée</string>
|
||||
<string name="how_it_works">Comment ça fonctionne</string>
|
||||
<string name="how_simplex_works">Comment <xliff:g id="appName">SimpleX</xliff:g> fonctionne</string>
|
||||
<string name="many_people_asked_how_can_it_deliver">Beaucoup se demande : <i>si <xliff:g id="appName">SimpleX</xliff:g> n\'a pas d\'identifiant d\'utilisateur, comment peut-il transmettre des messages \?</i></string>
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un <b>chiffrement de bout en bout à deux couches</b>.</string>
|
||||
<string name="read_more_in_github_with_link">Pour en savoir plus, consultez notre <font color="#0088ff">GitHub repository</font>.</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>Batterie peu utilisée</b>. Le service de fond vérifie les nouveaux messages toutes les 10 minutes. Vous risquez de manquer des appels et des messages urgents.</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>Batterie plus utilisée </b> ! Le service de fond est toujours en cours d\'exécution - les notifications s\'afficheront dès que les messages seront disponibles.</string>
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> message⸱s manqué⸱s</string>
|
||||
<string name="integrity_msg_bad_id">ID de message incorrecte</string>
|
||||
<string name="settings_section_title_settings">PARAMÈTRES</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">C\'est possible quand :
|
||||
\n1. Les messages expirent du serveur (après 30 jours si ils ne sont pas reçu).
|
||||
\n2. Le serveur que vous utilisez pour recevoir les messages de ce contact a été mise à jour ou redémarré.
|
||||
\n3. La connection est compromise.
|
||||
\nVeuillez vous connecter aux développeurs via les Paramètres pour recevoir les mises à jour concernant les serveurs.
|
||||
\nNous allons ajouter une redondance des serveurs pour éviter la perte de messages.</string>
|
||||
<string name="icon_descr_call_rejected">Appel rejeté</string>
|
||||
<string name="rcv_group_event_member_deleted">a retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="snd_group_event_member_deleted">vous avez retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="rcv_group_event_invited_via_your_group_link">invité par votre lien de groupe</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">vous avez changé d\'adresse</string>
|
||||
<string name="stop_chat_to_export_import_or_delete_chat_database">Arrêtez le chat pour exporter, importer ou supprimer la base de données du chat. Vous ne pourrez pas recevoir et envoyer de messages pendant que le chat est arrêté.</string>
|
||||
<string name="enable_automatic_deletion_message">Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes.</string>
|
||||
<string name="encrypted_with_random_passphrase">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire, que vous pouvez modifier.</string>
|
||||
<string name="restore_database">Restaurer la sauvegarde de la base de données</string>
|
||||
<string name="restore_passphrase_not_found_desc">La phrase secrète n\'a pas été trouvée dans le Keystore, veuillez la saisir manuellement. Cela a pu se produire si vous avez restauré les données de l\'app à l\'aide d\'un outil de sauvegarde. Si ce n\'est pas le cas, veuillez contacter les développeurs.</string>
|
||||
<string name="restore_database_alert_desc">Veuillez entrer le mot de passe précédent après avoir restauré la sauvegarde de la base de données. Cette action ne peut pas être annulée.</string>
|
||||
<string name="database_restore_error">Erreur de restauration de la base de données</string>
|
||||
<string name="archive_created_on_ts">Créé le <xliff:g id="archive_ts">%1$s</xliff:g></string>
|
||||
<string name="encrypted_video_call">appel vidéo (chiffrement de bout en bout)</string>
|
||||
<string name="audio_call_no_encryption">appel audio (sans chiffrement)</string>
|
||||
<string name="encrypted_audio_call">appel audio (chiffrement de bout en bout)</string>
|
||||
<string name="accept">Accepter</string>
|
||||
<string name="reject">Rejeter</string>
|
||||
<string name="icon_descr_video_call">appel vidéo</string>
|
||||
<string name="icon_descr_audio_call">appel audio</string>
|
||||
<string name="accept_call_on_lock_screen">Accepter</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">Activer les appels depuis l\'écran verrouillé via les Paramètres.</string>
|
||||
<string name="open_verb">Ouvrir</string>
|
||||
<string name="call_connection_via_relay">via relais</string>
|
||||
<string name="icon_descr_hang_up">Raccrocher</string>
|
||||
<string name="icon_descr_video_on">Vidéo ON</string>
|
||||
<string name="icon_descr_video_off">Vidéo OFF</string>
|
||||
<string name="icon_descr_call_progress">Appel en cours</string>
|
||||
<string name="icon_descr_call_ended">Appel terminé</string>
|
||||
<string name="your_privacy">Votre vie privée</string>
|
||||
<string name="settings_section_title_device">APPAREIL</string>
|
||||
<string name="settings_section_title_chats">CHATS</string>
|
||||
<string name="settings_developer_tools">Outils du développeur</string>
|
||||
<string name="settings_section_title_icon">ICONE DE L\'APP</string>
|
||||
<string name="your_chat_database">Votre base de données de chat</string>
|
||||
<string name="run_chat_section">LANCER LE CHAT</string>
|
||||
<string name="stop_chat_question">Arrêter le chat \?</string>
|
||||
<string name="restart_the_app_to_use_imported_chat_database">Redémarrez l\'application pour utiliser la base de données de chat importée.</string>
|
||||
<string name="data_section">DONNÉES</string>
|
||||
<string name="chat_item_ttl_day">1 jour</string>
|
||||
<string name="delete_messages">Supprimer les messages</string>
|
||||
<string name="save_passphrase_in_keychain">Sauvegarder la phrase secrète dans le keystore</string>
|
||||
<string name="database_encrypted">Base de données chiffrée !</string>
|
||||
<string name="error_encrypting_database">Erreur lors du chiffrement de la base de données</string>
|
||||
<string name="update_database">Mise à jour</string>
|
||||
<string name="encrypt_database">Chiffrer</string>
|
||||
<string name="enter_correct_current_passphrase">Veuillez entrer la phrase secrète actuelle correcte.</string>
|
||||
<string name="database_is_not_encrypted">Votre base de données de chat n\'est pas chiffrée - définissez une phrase secrète pour la protéger.</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Veuillez noter</b> : vous NE pourrez PAS récupérer ou modifier la phrase secrète si vous la perdez.</string>
|
||||
<string name="keychain_allows_to_receive_ntfs">Le Keystore d\'Android sera utilisé pour stocker en toute sécurité la phrase secrète après sa modification ou redémarrage de l\'app - cela permettra de recevoir les notifications.</string>
|
||||
<string name="store_passphrase_securely_without_recover">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez.</string>
|
||||
<string name="passphrase_is_different">La phrase secrète de la base de données est différente de celle enregistrée dans le Keystore.</string>
|
||||
<string name="unknown_error">Erreur inconnue</string>
|
||||
<string name="enter_correct_passphrase">Entrez la phrase secrète correcte.</string>
|
||||
<string name="alert_message_no_group">Ce groupe n\'existe plus.</string>
|
||||
<string name="you_joined_this_group">Vous avez rejoint ce groupe</string>
|
||||
<string name="you_rejected_group_invitation">Vous avez rejeté l\'invitation du groupe</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed_for_member">vous avez changé d\'adresse pour %s</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
|
||||
<string name="incoming_video_call">Appel vidéo entrant</string>
|
||||
<string name="video_call_no_encryption">appel vidéo (sans chiffrement)</string>
|
||||
<string name="ignore">Ignorer</string>
|
||||
<string name="call_already_ended">Appel déjà terminé !</string>
|
||||
<string name="settings_audio_video_calls">Appels audio et vidéo</string>
|
||||
<string name="status_e2e_encrypted">chiffré de bout en bout</string>
|
||||
<string name="settings_section_title_develop">DEVELOPPER</string>
|
||||
<string name="settings_experimental_features">Fonctionnalités expérimentales</string>
|
||||
<string name="settings_section_title_socks">SOCKS PROXY</string>
|
||||
<string name="settings_section_title_themes">THEMES</string>
|
||||
<string name="settings_section_title_messages">MESSAGES</string>
|
||||
<string name="settings_section_title_calls">APPELS</string>
|
||||
<string name="import_database">Importer la base de données</string>
|
||||
<string name="new_database_archive">Nouvelle archive de base de données</string>
|
||||
<string name="old_database_archive">Archives de l\'ancienne base de données</string>
|
||||
<string name="delete_database">Supprimer la base de données</string>
|
||||
<string name="error_starting_chat">Erreur lors du démarrage du chat</string>
|
||||
<string name="import_database_confirmation">Importer</string>
|
||||
<string name="delete_chat_profile_action_cannot_be_undone_warning">Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus.</string>
|
||||
<string name="chat_database_deleted">Base de données du chat supprimée</string>
|
||||
<string name="restart_the_app_to_create_a_new_chat_profile">Redémarrez l\'application pour créer un nouveau profil de chat.</string>
|
||||
<string name="you_must_use_the_most_recent_version_of_database">Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts.</string>
|
||||
<string name="total_files_count_and_size">%d fichier·s avec une taille totale de %s</string>
|
||||
<string name="chat_item_ttl_none">jamais</string>
|
||||
<string name="chat_item_ttl_week">1 semaine</string>
|
||||
<string name="database_will_be_encrypted_and_passphrase_stored">La base de données sera chiffrée et la phrase secrète sera stockée dans le Keystore.</string>
|
||||
<string name="database_encryption_will_be_updated">La phrase secrète de la base de données sera mise à jour et stockée dans le Keystore.</string>
|
||||
<string name="database_passphrase_will_be_updated">La phrase secrète de la base de données sera mise à jour.</string>
|
||||
<string name="store_passphrase_securely">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez.</string>
|
||||
<string name="wrong_passphrase">Mauvaise phrase secrète pour la base de données</string>
|
||||
<string name="encrypted_database">Base de données chiffrée</string>
|
||||
<string name="database_error">Erreur de base de données</string>
|
||||
<string name="error_with_info">Erreur : %s</string>
|
||||
<string name="cannot_access_keychain">Impossible d\'accéder au Keystore pour enregistrer le mot de passe de la base de données</string>
|
||||
<string name="unknown_database_error_with_info">Erreur de base de données inconnue : %s</string>
|
||||
<string name="wrong_passphrase_title">Mauvaise phrase secrète !</string>
|
||||
<string name="leave_group_question">Quitter le groupe \?</string>
|
||||
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Vous ne recevrez plus de messages de ce groupe. L\'historique du chat sera conservé.</string>
|
||||
<string name="icon_descr_add_members">Inviter des membres</string>
|
||||
<string name="icon_descr_group_inactive">Groupe inactif</string>
|
||||
<string name="alert_title_group_invitation_expired">Invitation expirée !</string>
|
||||
<string name="alert_message_group_invitation_expired">L\'invitation du groupe n\'est plus valide, elle a été supprimé par l\'expéditeur.</string>
|
||||
<string name="alert_title_no_group">Groupe introuvable !</string>
|
||||
<string name="alert_title_cant_invite_contacts">Impossible d\'inviter les contacts !</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">Vous utilisez un profil incognito pour ce groupe - pour éviter de partager votre profil principal ; inviter des contacts n\'est pas possible</string>
|
||||
<string name="you_sent_group_invitation">Vous avez envoyé une invitation de groupe</string>
|
||||
<string name="rcv_group_event_member_left">a quitté</string>
|
||||
<string name="icon_descr_speaker_on">Haut-parleur ON</string>
|
||||
<string name="send_link_previews">Envoi d\'aperçus de liens</string>
|
||||
<string name="error_deleting_database">Erreur lors de la suppression de la base de données du chat</string>
|
||||
<string name="error_stopping_chat">Erreur lors de l\'arrêt du chat</string>
|
||||
<string name="error_exporting_chat_database">Erreur lors de l\'exportation de la base de données du chat</string>
|
||||
<string name="import_database_question">Importer la base de données du chat \?</string>
|
||||
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Votre base de données de chat actuelle sera SUPPRIMÉE et REMPLACÉE par celle qui a été importée.
|
||||
\nCette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irrémédiablement perdus.</string>
|
||||
<string name="enter_passphrase">Entrez la phrase secrète…</string>
|
||||
<string name="incoming_audio_call">Appel audio entrant</string>
|
||||
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> veut se connecter à vous via</string>
|
||||
<string name="your_calls">Vos appels</string>
|
||||
<string name="connect_calls_via_relay">Se connecter via relais</string>
|
||||
<string name="call_on_lock_screen">Appels en écran verrouillé :</string>
|
||||
<string name="show_call_on_lock_screen">Montrer</string>
|
||||
<string name="no_call_on_lock_screen">Désactiver</string>
|
||||
<string name="your_ice_servers">Vos serveurs ICE</string>
|
||||
<string name="webrtc_ice_servers">Serveurs WebRTC ICE</string>
|
||||
<string name="open_simplex_chat_to_accept_call">Ouvrez <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour décrocher</string>
|
||||
<string name="status_no_e2e_encryption">sans chiffrement de bout en bout</string>
|
||||
<string name="status_contact_has_e2e_encryption">Ce contact a le chiffrement de bout en bout</string>
|
||||
<string name="status_contact_has_no_e2e_encryption">Ce contact n\'a pas le chiffrement de bout en bout</string>
|
||||
<string name="call_connection_peer_to_peer">pair-à-pair</string>
|
||||
<string name="icon_descr_audio_off">Audio OFF</string>
|
||||
<string name="icon_descr_audio_on">Audio ON</string>
|
||||
<string name="icon_descr_speaker_off">Haut-parleur OFF</string>
|
||||
<string name="icon_descr_flip_camera">Retourner la caméra</string>
|
||||
<string name="icon_descr_call_pending_sent">Appel en suspend</string>
|
||||
<string name="icon_descr_call_missed">Appel manqué</string>
|
||||
<string name="icon_descr_call_connecting">Appel en connexion</string>
|
||||
<string name="answer_call">Répondre à l\'appel</string>
|
||||
<string name="integrity_msg_bad_hash">hash de message incorrect</string>
|
||||
<string name="integrity_msg_duplicate">message dupliqué</string>
|
||||
<string name="alert_title_skipped_messages">Messages manqués</string>
|
||||
<string name="privacy_and_security">Vie privée et sécurité</string>
|
||||
<string name="protect_app_screen">Protéger l\'écran de l\'app</string>
|
||||
<string name="auto_accept_images">Images auto-acceptées</string>
|
||||
<string name="transfer_images_faster">Transfert d\'images plus rapide</string>
|
||||
<string name="full_backup">Sauvegarde des données de l\'app</string>
|
||||
<string name="settings_section_title_you">VOUS</string>
|
||||
<string name="settings_section_title_help">AIDE</string>
|
||||
<string name="settings_section_title_support">SOUTENEZ SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_incognito">Mode Incognito</string>
|
||||
<string name="chat_is_running">Le chat est en cours d\'exécution</string>
|
||||
<string name="chat_is_stopped">Le chat est arrêté</string>
|
||||
<string name="chat_database_section">BASE DE DONNÉES DU CHAT</string>
|
||||
<string name="database_passphrase">Phrase secrète de la base de données</string>
|
||||
<string name="export_database">Exporter la base de données</string>
|
||||
<string name="stop_chat_confirmation">Arrêter</string>
|
||||
<string name="set_password_to_export">Définir la phrase secrète pour l\'export</string>
|
||||
<string name="set_password_to_export_desc">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire. Veuillez la changer avant d\'exporter.</string>
|
||||
<string name="error_importing_database">Erreur lors de l\'importation de la base de données du chat</string>
|
||||
<string name="chat_database_imported">Base de données du chat importée</string>
|
||||
<string name="delete_chat_profile_question">Supprimer le profil du chat \?</string>
|
||||
<string name="stop_chat_to_enable_database_actions">Arrêter le chat pour agir sur la base de données.</string>
|
||||
<string name="delete_files_and_media_question">Supprimer les fichiers et médias \?</string>
|
||||
<string name="delete_files_and_media">"Supprimer les fichiers médias"</string>
|
||||
<string name="delete_files_and_media_desc">Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées.</string>
|
||||
<string name="no_received_app_files">Aucun fichier reçu ou envoyé</string>
|
||||
<string name="chat_item_ttl_month">1 mois</string>
|
||||
<string name="chat_item_ttl_seconds">%s seconde·s</string>
|
||||
<string name="delete_messages_after">Supprimer les messages après</string>
|
||||
<string name="enable_automatic_deletion_question">Activer la suppression automatique des messages \?</string>
|
||||
<string name="error_changing_message_deletion">Erreur de changement de paramètre</string>
|
||||
<string name="remove_passphrase_from_keychain">Retirer la phrase secrète du Keystore \?</string>
|
||||
<string name="notifications_will_be_hidden">Les notifications seront délivrées jusqu\'à ce que l\'application s\'arrête !</string>
|
||||
<string name="remove_passphrase">Supprimer</string>
|
||||
<string name="current_passphrase">Phrase secrète actuelle…</string>
|
||||
<string name="new_passphrase">Nouvelle phrase secrète…</string>
|
||||
<string name="confirm_new_passphrase">Confirmer la nouvelle phrase secrète…</string>
|
||||
<string name="update_database_passphrase">Mise à jour de la phrase secrète de la base de données</string>
|
||||
<string name="keychain_is_storing_securely">Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
|
||||
<string name="you_have_to_enter_passphrase_every_time">Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil.</string>
|
||||
<string name="encrypt_database_question">Chiffrer la base de données \?</string>
|
||||
<string name="change_database_passphrase_question">Changer la phrase secrète de la base de données \?</string>
|
||||
<string name="database_will_be_encrypted">La base de données sera chiffrée.</string>
|
||||
<string name="keychain_error">Erreur de la keychain</string>
|
||||
<string name="file_with_path">Fichier : %s</string>
|
||||
<string name="database_passphrase_is_required">La phrase secrète de la base de données est nécessaire pour ouvrir le chat.</string>
|
||||
<string name="save_passphrase_and_open_chat">Sauvegarder la phrase secrète et ouvrir le chat</string>
|
||||
<string name="open_chat">Ouvrir le chat</string>
|
||||
<string name="database_backup_can_be_restored">La tentative de modification de la phrase secrète de la base de données n\'a pas abouti.</string>
|
||||
<string name="restore_database_alert_title">Restaurer la sauvegarde de la base de données \?</string>
|
||||
<string name="restore_database_alert_confirm">Restaurer</string>
|
||||
<string name="chat_is_stopped_indication">Le chat est arrêté</string>
|
||||
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Vous pouvez lancer le chat via les Paramètres / la Base de données de l\'app ou en la redémarrant.</string>
|
||||
<string name="chat_archive_header">Archives du chat</string>
|
||||
<string name="chat_archive_section">ARCHIVE DU CHAT</string>
|
||||
<string name="save_archive">Sauvegarder l\'archive</string>
|
||||
<string name="delete_archive">Supprimer l\'archive</string>
|
||||
<string name="delete_chat_archive_question">Supprimer l\'archive du chat \?</string>
|
||||
<string name="group_invitation_item_description">Invitation au groupe <xliff:g id="group_name">%1$s</xliff:g></string>
|
||||
<string name="join_group_question">Rejoindre le groupe \?</string>
|
||||
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Vous êtes invité·e dans un groupe. Rejoignez le pour vous connecter avec ses membres.</string>
|
||||
<string name="join_group_button">Rejoindre</string>
|
||||
<string name="join_group_incognito_button">Rejoindre en incognito</string>
|
||||
<string name="joining_group">Entrain de rejoindre le groupe</string>
|
||||
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Vous avez rejoint ce groupe. Connexion à l\'invitation d\'un membre du groupe.</string>
|
||||
<string name="leave_group_button">Quitter</string>
|
||||
<string name="you_are_invited_to_group">Vous êtes invité·e au groupe</string>
|
||||
<string name="group_invitation_tap_to_join">Appuyez pour rejoindre</string>
|
||||
<string name="group_invitation_tap_to_join_incognito">Appuyez pour rejoindre incognito</string>
|
||||
<string name="group_invitation_expired">Invitation au groupe expirée</string>
|
||||
<string name="rcv_group_event_member_added">a invité <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="rcv_group_event_member_connected">est connecté·e</string>
|
||||
<string name="rcv_group_event_changed_member_role">a modifié le rôle de %s pour %s</string>
|
||||
<string name="rcv_group_event_changed_your_role">a modifié votre rôle pour %s</string>
|
||||
<string name="rcv_group_event_user_deleted">vous a retiré</string>
|
||||
<string name="rcv_group_event_group_deleted">a supprimé le groupe</string>
|
||||
<string name="rcv_group_event_updated_group_profile">mise à jour du profil de groupe</string>
|
||||
<string name="snd_group_event_changed_member_role">vous avez modifié le rôle de %s pour %s</string>
|
||||
<string name="snd_group_event_changed_role_for_yourself">vous avez modifié votre rôle pour %s</string>
|
||||
<string name="snd_group_event_user_left">vous avez quitté</string>
|
||||
<string name="snd_group_event_group_profile_updated">mise à jour du profil de groupe</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">adresse modifiée pour vous</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">changement d\'adresse pour %s…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
|
||||
<string name="group_member_role_member">membre</string>
|
||||
<string name="group_member_role_admin">admin</string>
|
||||
<string name="group_member_role_owner">propriétaire</string>
|
||||
<string name="group_member_status_removed">supprimé</string>
|
||||
<string name="group_member_status_left">a quitté</string>
|
||||
<string name="group_member_status_group_deleted">groupe supprimé</string>
|
||||
<string name="group_member_status_invited">invité·e</string>
|
||||
<string name="group_member_status_introduced">connexion (introduite)</string>
|
||||
<string name="group_member_status_intro_invitation">connexion (introduite par invitation)</string>
|
||||
<string name="group_member_status_accepted">connexion (acceptée)</string>
|
||||
<string name="group_member_status_announced">connexion (annoncée)</string>
|
||||
<string name="group_member_status_connected">connecté</string>
|
||||
<string name="group_member_status_complete">complet</string>
|
||||
<string name="group_member_status_creator">créateur</string>
|
||||
<string name="group_member_status_connecting">connexion</string>
|
||||
<string name="no_contacts_to_add">Aucun contact à ajouter</string>
|
||||
<string name="new_member_role">Nouveau rôle</string>
|
||||
<string name="delete_group_question">Supprimer le groupe \?</string>
|
||||
<string name="group_link">Lien du groupe</string>
|
||||
<string name="button_create_group_link">Créer un lien</string>
|
||||
<string name="button_edit_group_profile">Modifier le profil du groupe</string>
|
||||
<string name="remove_member_confirmation">Supprimer</string>
|
||||
<string name="member_info_section_title_member">MEMBRE</string>
|
||||
<string name="live_message">Message dynamique !</string>
|
||||
<string name="send_live_message">Envoyer un message dynamique</string>
|
||||
<string name="send_live_message_desc">Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez</string>
|
||||
<string name="send_verb">Envoyer</string>
|
||||
<string name="member_role_will_be_changed_with_invitation">Le rôle sera changé pour «%s». Le membre va recevoir une nouvelle invitation.</string>
|
||||
<string name="live">LIVE</string>
|
||||
<string name="button_add_members">Inviter des membres</string>
|
||||
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Vous pouvez partager un lien ou un code QR - n\'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite.</string>
|
||||
<string name="info_row_local_name">Nom local</string>
|
||||
<string name="create_group_link">Créer un lien de groupe</string>
|
||||
<string name="error_deleting_link_for_group">Erreur lors de la suppression du lien du groupe</string>
|
||||
<string name="error_creating_link_for_group">Erreur lors de la création du lien du groupe</string>
|
||||
<string name="only_group_owners_can_change_prefs">Seuls les propriétaires du groupe peuvent modifier les préférences du groupe.</string>
|
||||
<string name="section_title_for_console">POUR TERMINAL</string>
|
||||
<string name="change_member_role_question">Changer le rôle du groupe \?</string>
|
||||
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour «%s». Les membres du groupe seront notifiés.</string>
|
||||
<string name="icon_descr_contact_checked">Contact vérifié⸱e</string>
|
||||
<string name="clear_contacts_selection_button">Effacer</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact·s sélectionné·e·s</string>
|
||||
<string name="skip_inviting_button">Passer l’invitation de membres</string>
|
||||
<string name="select_contacts">Sélectionnez des contacts</string>
|
||||
<string name="no_contacts_selected">Aucun contact sélectionné</string>
|
||||
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRES</string>
|
||||
<string name="group_info_member_you">vous : <xliff:g id="group_info_you">%1$s</xliff:g></string>
|
||||
<string name="button_delete_group">Supprimer le groupe</string>
|
||||
<string name="delete_group_for_all_members_cannot_undo_warning">Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière !</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">Le groupe va être supprimé pour vous - impossible de revenir en arrière !</string>
|
||||
<string name="button_leave_group">Quitter le groupe</string>
|
||||
<string name="delete_link_question">Supprimer le lien \?</string>
|
||||
<string name="delete_link">Supprimer le lien</string>
|
||||
<string name="all_group_members_will_remain_connected">Tous les membres du groupe resteront connectés.</string>
|
||||
<string name="icon_descr_expand_role">Étendre la sélection de rôle</string>
|
||||
<string name="invite_to_group_button">Inviter au groupe</string>
|
||||
<string name="invite_prohibited">Impossible d\'inviter le contact !</string>
|
||||
<string name="invite_prohibited_description">Vous essayez d\'inviter un contact avec lequel vous avez partagé un profil incognito à rejoindre le groupe dans lequel vous utilisez votre profil principal</string>
|
||||
<string name="info_row_database_id">ID de base de données</string>
|
||||
<string name="button_remove_member">Retirer le membre</string>
|
||||
<string name="button_send_direct_message">Envoi de message direct</string>
|
||||
<string name="member_will_be_removed_from_group_cannot_be_undone">Ce membre sera retiré du groupe - impossible de revenir en arrière !</string>
|
||||
<string name="role_in_group">Rôle</string>
|
||||
<string name="change_role">Changer le rôle</string>
|
||||
<string name="change_verb">Changer</string>
|
||||
<string name="switch_verb">Échanger</string>
|
||||
<string name="error_removing_member">Erreur lors de la suppression d\'un membre</string>
|
||||
<string name="error_changing_role">Erreur lors du changement de rôle</string>
|
||||
<string name="group_full_name_field">Nom complet du groupe :</string>
|
||||
<string name="update_network_settings_confirmation">Mise à jour</string>
|
||||
<string name="chat_preferences_on">on</string>
|
||||
<string name="chat_preferences_off">off</string>
|
||||
<string name="direct_messages">Messages dynamiques</string>
|
||||
<string name="full_deletion">Supprimer pour tous</string>
|
||||
<string name="only_you_can_delete_messages">Vous êtes le seul à pouvoir supprimer des messages de manière irréversible (votre contact peut les marquer pour suppression).</string>
|
||||
<string name="conn_stats_section_title_servers">SERVEURS</string>
|
||||
<string name="receiving_via">Réception via</string>
|
||||
<string name="theme_system">Système</string>
|
||||
<string name="allow_direct_messages">Autoriser l\'envoi de messages directs aux membres.</string>
|
||||
<string name="prohibit_direct_messages">Interdire l\'envoi de messages directs aux membres.</string>
|
||||
<string name="group_members_can_delete">Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">La suppression irréversible de messages est interdite dans ce groupe.</string>
|
||||
<string name="sending_via">Envoyé via</string>
|
||||
<string name="network_status">État du réseau</string>
|
||||
<string name="switch_receiving_address">Changer d\'adresse de réception</string>
|
||||
<string name="create_secret_group_title">Créer un groupe secret</string>
|
||||
<string name="group_main_profile_sent">Votre profil de chat sera envoyé aux membres du groupe</string>
|
||||
<string name="network_option_enable_tcp_keep_alive">Activer le TCP keep-alive</string>
|
||||
<string name="network_options_save">Sauvegarder</string>
|
||||
<string name="update_network_settings_question">Mettre à jour les paramètres réseau \?</string>
|
||||
<string name="incognito">Incognito</string>
|
||||
<string name="incognito_random_profile">Votre profil aléatoire</string>
|
||||
<string name="incognito_random_profile_description">Un profil aléatoire sera envoyé à votre contact</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Un profil aléatoire sera envoyé au contact qui vous a envoyé ce lien</string>
|
||||
<string name="incognito_info_allows">Cela permet d\'avoir plusieurs connections anonymes sans aucune données partagées entre elles sur un même profil.</string>
|
||||
<string name="incognito_info_find">Pour trouver le profil utilisé lors d\'une connexion incognito, appuyez sur le nom du contact ou du groupe en haut du chat.</string>
|
||||
<string name="theme_light">Clair</string>
|
||||
<string name="theme_dark">Sombre</string>
|
||||
<string name="theme">Thème</string>
|
||||
<string name="save_color">Sauvegarder la couleur</string>
|
||||
<string name="reset_color">Réinitialisation des couleurs</string>
|
||||
<string name="color_primary">Principale</string>
|
||||
<string name="chat_preferences_you_allow">Vous autorisez</string>
|
||||
<string name="chat_preferences_contact_allows">Votre contact autorise</string>
|
||||
<string name="chat_preferences_default">par défaut (%s)</string>
|
||||
<string name="chat_preferences_no">non</string>
|
||||
<string name="chat_preferences_always">toujours</string>
|
||||
<string name="chat_preferences">Préférences de chat</string>
|
||||
<string name="contact_preferences">Préférences de contact</string>
|
||||
<string name="group_preferences">Préférences du groupe</string>
|
||||
<string name="set_group_preferences">Définir les préférences du groupe</string>
|
||||
<string name="your_preferences">Vos préférences</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Autorise votre contact à supprimer de façon définitive des messages envoyés.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Autorise vos contacts à envoyer des messages vocaux.</string>
|
||||
<string name="allow_voice_messages_only_if">Autoriser les messages vocaux uniquement si votre contact les autorise.</string>
|
||||
<string name="prohibit_sending_voice_messages">Interdire l\'envoi de messages vocaux.</string>
|
||||
<string name="only_you_can_send_disappearing">Seulement vous pouvez envoyer des messages éphémères.</string>
|
||||
<string name="only_you_can_send_voice">Vous seul pouvez envoyer des messages vocaux.</string>
|
||||
<string name="allow_to_delete_messages">Autoriser la suppression irréversible de messages envoyés.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Les messages éphémères sont interdits dans ce groupe.</string>
|
||||
<string name="group_members_can_send_voice">Les membres du groupe peuvent envoyer des messages vocaux.</string>
|
||||
<string name="delete_after">Supprimer après</string>
|
||||
<string name="ttl_sec">%d sec</string>
|
||||
<string name="ttl_s">%ds</string>
|
||||
<string name="ttl_min">%d min</string>
|
||||
<string name="ttl_month">%d mois</string>
|
||||
<string name="ttl_months">%d mois</string>
|
||||
<string name="ttl_m">%dm</string>
|
||||
<string name="ttl_mth">%dm</string>
|
||||
<string name="ttl_hour">%d heure</string>
|
||||
<string name="ttl_hours">%d heures</string>
|
||||
<string name="ttl_h">%dh</string>
|
||||
<string name="ttl_day">%d jour</string>
|
||||
<string name="ttl_days">%d jours</string>
|
||||
<string name="ttl_d">%dj</string>
|
||||
<string name="ttl_week">%d semaine</string>
|
||||
<string name="ttl_weeks">%d semaines</string>
|
||||
<string name="ttl_w">%dsmn</string>
|
||||
<string name="timed_messages">Messages éphémères</string>
|
||||
<string name="voice_messages">Messages vocaux</string>
|
||||
<string name="feature_enabled">activé</string>
|
||||
<string name="feature_enabled_for_you">activé pour vous</string>
|
||||
<string name="feature_enabled_for_contact">activé pour le contact</string>
|
||||
<string name="feature_off">off</string>
|
||||
<string name="feature_received_prohibited">reçu, non autorisé</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Autorise votre contact à envoyer des messages éphémères.</string>
|
||||
<string name="conn_level_desc_direct">directe</string>
|
||||
<string name="group_is_decentralized">Le groupe est entièrement décentralisé – il n\'est visible que par ses membres.</string>
|
||||
<string name="group_members_can_send_disappearing">Les membres du groupes peuvent envoyer des messages éphémères.</string>
|
||||
<string name="network_options_revert">Revenir en arrière</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Interdit l’envoi de messages éphémères.</string>
|
||||
<string name="incognito_info_protects">Le mode Incognito protège la confidentialité de votre profil principal — pour chaque nouveau contact un nouveau profil aléatoire est créé.</string>
|
||||
<string name="updating_settings_will_reconnect_client_to_all_servers">La mise à jour des ces paramètres reconnectera le client à tous les serveurs.</string>
|
||||
<string name="incognito_info_share">Lorsque vous partagez un profil incognito avec quelqu\'un, ce profil sera utilisé pour les groupes auxquels il vous invite.</string>
|
||||
<string name="chat_preferences_yes">oui</string>
|
||||
<string name="allow_disappearing_messages_only_if">Autorise les messages éphémères seulement si votre contact les autorises.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise.</string>
|
||||
<string name="only_your_contact_can_delete">Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer pour suppression).</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Seulement votre contact peut envoyer des messages éphémères.</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Vous et votre contact peuvent envoyer des messages éphémères.</string>
|
||||
<string name="voice_messages_are_prohibited">Les messages vocaux sont interdits dans ce groupe.</string>
|
||||
<string name="group_display_name_field">Nom affiché du groupe :</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">Le mode Incognito n\'est pas supporté ici - votre profil principal sera envoyé aux membres du groupe</string>
|
||||
<string name="conn_level_desc_indirect">indirecte (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
<string name="info_row_group">Groupe</string>
|
||||
<string name="info_row_connection">Connexion</string>
|
||||
<string name="network_option_seconds_label">sec</string>
|
||||
<string name="network_option_tcp_connection_timeout">Délai de connexion TCP</string>
|
||||
<string name="group_profile_is_stored_on_members_devices">Le profil du groupe est stocké sur les appareils des membres, pas sur les serveurs.</string>
|
||||
<string name="save_group_profile">Sauvegarder le profil du groupe</string>
|
||||
<string name="error_saving_group_profile">Erreur lors de la sauvegarde du profil de groupe</string>
|
||||
<string name="network_options_reset_to_defaults">Réinitialisation des valeurs par défaut</string>
|
||||
<string name="network_option_protocol_timeout">Délai du protocole</string>
|
||||
<string name="network_option_ping_interval">Intervalle de PING</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
|
||||
<string name="message_deletion_prohibited">La suppression irréversible de message est interdite dans ce chat.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
|
||||
<string name="only_your_contact_can_send_voice">Seul votre contact peut envoyer des messages vocaux.</string>
|
||||
<string name="voice_prohibited_in_this_chat">Les messages vocaux sont interdits dans ce chat.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Les messages éphémères sont interdits dans cette discussion.</string>
|
||||
<string name="allow_to_send_voice">Autoriser l\'envoi de messages vocaux.</string>
|
||||
<string name="prohibit_sending_voice">Interdire l\'envoi de messages vocaux.</string>
|
||||
<string name="allow_to_send_disappearing">Autorise l’envoi de messages éphémères.</string>
|
||||
<string name="prohibit_sending_disappearing">Interdit l’envoi de messages éphémères.</string>
|
||||
<string name="prohibit_message_deletion">Interdire la suppression irréversible des messages.</string>
|
||||
<string name="group_members_can_send_dms">Les membres du groupe peuvent envoyer des messages directs.</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Les messages directs entre membres sont interdits dans ce groupe.</string>
|
||||
</resources>
|
||||
@@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
|
||||
<string name="thousand_abbreviation">т</string>
|
||||
|
||||
<!-- Connect via Link - MainActivity.kt -->
|
||||
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
|
||||
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
|
||||
@@ -10,7 +9,6 @@
|
||||
<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 -->
|
||||
<string name="server_connected">соединено</string>
|
||||
<string name="server_error">ошибка</string>
|
||||
@@ -18,15 +16,14 @@
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
|
||||
|
||||
<!-- Item Content - ChatModel.kt -->
|
||||
<string name="deleted_description">удалено</string>
|
||||
<string name="marked_deleted_description">помечено к удалению</string>
|
||||
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
|
||||
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
|
||||
<string name="sender_you_pronoun">вы</string>
|
||||
<string name="unknown_message_format">неизвестный формат сообщения</string>
|
||||
<string name="invalid_message_format">неверный формат сообщения</string>
|
||||
|
||||
<!-- PendingContactConnection - ChatModel.kt -->
|
||||
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="display_name_connection_established">соединение установлено</string>
|
||||
@@ -40,16 +37,24 @@
|
||||
<string name="description_via_contact_address_link_incognito">инкогнито через ссылку-контакт</string>
|
||||
<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>
|
||||
<string name="error_setting_network_config">Ошибка при сохранении настроек сети</string>
|
||||
|
||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||
<string name="connection_timeout">Превышено время соединения</string>
|
||||
<string name="connection_error">Ошибка соединения</string>
|
||||
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.</string>
|
||||
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сервером <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> и попробуйте еще раз.</string>
|
||||
<string name="error_sending_message">Ошибка при отправке сообщения</string>
|
||||
<string name="error_adding_members">Ошибка при добавлении членов группы</string>
|
||||
<string name="error_joining_group">Ошибка при вступлении в группу</string>
|
||||
@@ -70,7 +75,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>
|
||||
<string name="service_notifications">Мгновенные уведомления!</string>
|
||||
@@ -86,17 +98,13 @@
|
||||
<string name="enter_passphrase_notification_desc">Для получения уведомлений, пожалуйста, введите пароль от базы данных</string>
|
||||
<string name="database_initialization_error_title">Ошибка базы данных</string>
|
||||
<string name="database_initialization_error_desc">Ошибка при инициализации базы данных. Нажмите чтобы узнать больше</string>
|
||||
|
||||
<!-- SimpleX Chat foreground Service -->
|
||||
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
|
||||
<string name="simplex_service_notification_text">Приём сообщений…</string>
|
||||
<string name="hide_notification">Скрыть</string>
|
||||
|
||||
<!-- Notification channels -->
|
||||
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
|
||||
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
|
||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat звонки (экран блокировки)</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
|
||||
<string name="settings_notification_preview_mode_title">Показывать уведомления</string>
|
||||
@@ -117,12 +125,10 @@
|
||||
<string name="notification_preview_new_message">новое сообщение</string>
|
||||
<string name="notification_new_contact_request">Новый запрос на соединение</string>
|
||||
<string name="notification_contact_connected">Соединен(а)</string>
|
||||
|
||||
<!-- local authentication notice - SimpleXAPI.kt -->
|
||||
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
|
||||
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
|
||||
<string name="la_notice_turn_on">Включить</string>
|
||||
|
||||
<!-- LocalAuthentication.kt -->
|
||||
<string name="auth_simplex_lock_turned_on">Блокировка SimpleX включена</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
|
||||
@@ -136,11 +142,9 @@
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</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>
|
||||
@@ -148,18 +152,20 @@
|
||||
<string name="save_verb">Сохранить</string>
|
||||
<string name="edit_verb">Редактировать</string>
|
||||
<string name="delete_verb">Удалить</string>
|
||||
<string name="reveal_verb">Показать</string>
|
||||
<string name="hide_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>
|
||||
<string name="delete_message_mark_deleted_warning">Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение.</string>
|
||||
<string name="for_me_only">Удалить для меня</string>
|
||||
<string name="for_everybody">Для всех</string>
|
||||
|
||||
<!-- CIMetaView.kt -->
|
||||
<string name="icon_descr_edited">отредактировано</string>
|
||||
<string name="icon_descr_sent_msg_status_sent">отправлено</string>
|
||||
<string name="icon_descr_sent_msg_status_unauthorized_send">ошибка авторизации при отправке</string>
|
||||
<string name="icon_descr_sent_msg_status_send_failed">ошибка при отправке</string>
|
||||
<string name="icon_descr_received_msg_status_unread">не прочитано</string>
|
||||
|
||||
<!-- ChatListView.kt -->
|
||||
<string name="personal_welcome">Здравствуйте <xliff:g>%1$s</xliff:g>!</string>
|
||||
<string name="welcome">Здравствуйте!</string>
|
||||
@@ -172,12 +178,10 @@
|
||||
<string name="tap_to_start_new_chat">Нажмите, чтобы начать чат</string>
|
||||
<string name="chat_with_developers">Соединиться с разработчиками</string>
|
||||
<string name="you_have_no_chats">У вас нет чатов</string>
|
||||
|
||||
<!-- ShareListView.kt -->
|
||||
<string name="share_message">Отправить сообщение…</string>
|
||||
<string name="share_image">Отправить изображение…</string>
|
||||
<string name="share_file">Отправить файл…</string>
|
||||
|
||||
<!-- ComposeView.kt, helpers -->
|
||||
<string name="attach">Прикрепить</string>
|
||||
<string name="icon_descr_context">Значок контекста</string>
|
||||
@@ -187,7 +191,6 @@
|
||||
<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>
|
||||
<string name="icon_descr_waiting_for_image">Ожидается прием изображения</string>
|
||||
@@ -196,7 +199,6 @@
|
||||
<string name="waiting_for_image">Ожидается прием изображения</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
|
||||
<string name="image_saved">Изображение сохранено в Галерею</string>
|
||||
|
||||
<!-- Files - CIFileView.kt -->
|
||||
<string name="icon_descr_file">Файл</string>
|
||||
<string name="large_file">Большой файл!</string>
|
||||
@@ -207,10 +209,12 @@
|
||||
<string name="file_saved">Файл сохранен</string>
|
||||
<string name="file_not_found">Файл не найден</string>
|
||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">Голосовое сообщение</string>
|
||||
<string name="voice_message_with_duration">Голосовое сообщение (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||
<string name="voice_message_send_text">Голосовое сообщение…</string>
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Уведомления</string>
|
||||
|
||||
<!-- Chat Info Actions - ChatInfoView.kt -->
|
||||
<string name="delete_contact_question">Удалить контакт?</string>
|
||||
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
|
||||
@@ -222,19 +226,23 @@
|
||||
<string name="icon_descr_server_status_pending">Ожидается соединение с сервером</string>
|
||||
<string name="switch_receiving_address_question">Переключить адрес получения?</string>
|
||||
<string name="switch_receiving_address_desc">Это экспериментальная функция! Она будет работать, только если на другом клиенте установлена версия 4.2. После завершения смены адреса вы увидите сообщение — убедитесь, что вы все еще можете получать сообщения от этого контакта (или члена группы).</string>
|
||||
|
||||
<!-- 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>
|
||||
<string name="copied">Скопировано в буфер обмена</string>
|
||||
|
||||
<!-- NewChatSheet -->
|
||||
<string name="add_contact_or_create_group">Начать новый разговор</string>
|
||||
<string name="share_one_time_link">Создать ссылку-приглашение</string>
|
||||
@@ -244,13 +252,11 @@
|
||||
<string name="to_share_with_your_contact">(чтобы отправить вашему контакту)</string>
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(сканировать или вставить из буфера)</string>
|
||||
<string name="only_stored_on_members_devices">(хранится только у членов группы)</string>
|
||||
|
||||
<!-- GetImageView -->
|
||||
<string name="toast_permission_denied">Разрешение не получено!</string>
|
||||
<string name="use_camera_button">Камера</string>
|
||||
<string name="from_gallery_button">Галерея</string>
|
||||
<string name="choose_file">Файлы</string>
|
||||
|
||||
<!-- help - ChatHelpView.kt -->
|
||||
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||
<string name="you_can_connect_to_simplex_chat_founder">Вы можете <font color="#0088ff">соединиться с разработчиками</font>, чтобы задать любые вопросы или получать уведомления о новых версиях.</string>
|
||||
@@ -263,14 +269,12 @@
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Если вы получили ссылку с приглашением из <xliff:g id="appName">SimpleX Chat</xliff:g>, вы можете открыть ее в браузере:</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 на компьютере: сосканируйте показанный QR код из приложения через <b>Сканировать QR код</b>.</string>
|
||||
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 на мобильном: намжите кнопку <b>Open in mobile app</b> на веб странице, затем нажмите <b>Соединиться</b> в приложении.</string>
|
||||
|
||||
<!-- Contact Request Alert Dialogue - CharListNavLinkView.kt -->
|
||||
<string name="accept_connection_request__question">Принять запрос на соединение?</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение.</string>
|
||||
<string name="accept_contact_button">Принять</string>
|
||||
<string name="accept_contact_incognito_button">Принять инкогнито</string>
|
||||
<string name="reject_contact_button">Отклонить</string>
|
||||
|
||||
<!-- Clear Chat - ChatListNavLinkView.kt -->
|
||||
<string name="clear_chat_question">Очистить чат?</string>
|
||||
<string name="clear_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</string>
|
||||
@@ -282,29 +286,23 @@
|
||||
<string name="mark_read">Прочитано</string>
|
||||
<string name="mark_unread">Не прочитано</string>
|
||||
<string name="set_contact_name">Имя контакта</string>
|
||||
|
||||
<!-- Actions - ChatListNavLinkView.kt -->
|
||||
<string name="mute_chat">Без звука</string>
|
||||
<string name="unmute_chat">Уведомлять</string>
|
||||
|
||||
<!-- Pending contact connection alert dialogues -->
|
||||
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
|
||||
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
|
||||
<string name="delete_pending_connection__question">Удалить ожидаемое соединение?</string>
|
||||
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому вы отправили эту ссылку, не сможет соединиться!</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">Подтвержденное соединение будет отменено!</string>
|
||||
|
||||
<!-- Connection Pending Alert Dialogue - ChatListNavLinkView.kt -->
|
||||
<string name="alert_title_contact_connection_pending">Соединение еще не установлено!</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).</string>
|
||||
|
||||
<!-- Contact Request Information - ContactRequestView.kt -->
|
||||
<string name="contact_wants_to_connect_with_you">хочет соединиться с вами!</string>
|
||||
|
||||
<!-- Image Placeholder - ChatInfoImage.kt -->
|
||||
<string name="icon_descr_profile_image_placeholder">аватар не установлен</string>
|
||||
<string name="image_descr_profile_image">аватар</string>
|
||||
|
||||
<!-- Content Descriptions -->
|
||||
<string name="icon_descr_close_button">закрыть</string>
|
||||
<string name="image_descr_link_preview">изображение превью ссылки</string>
|
||||
@@ -317,10 +315,8 @@
|
||||
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> логотип</string>
|
||||
<string name="icon_descr_email">Email</string>
|
||||
<string name="icon_descr_more_button">Больше</string>
|
||||
|
||||
<!-- Connection info - ContactConnectionInfoView.kt -->
|
||||
<string name="show_QR_code">Показать QR код</string>
|
||||
|
||||
<!-- Add Contact - AddContactView.kt -->
|
||||
<string name="invalid_QR_code">Неверный QR код</string>
|
||||
<string name="this_QR_code_is_not_a_link">Этот QR код не является ссылкой!</string>
|
||||
@@ -337,16 +333,13 @@
|
||||
<string name="share_invitation_link">Поделиться ссылкой</string>
|
||||
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
|
||||
<string name="your_profile_will_be_sent">Ваш профиль будет отправлен вашему контакту</string>
|
||||
|
||||
<!-- PasteToConnect.kt -->
|
||||
<string name="connect_button">Соединиться</string>
|
||||
<string name="paste_button">Вставить</string>
|
||||
|
||||
<!-- CreateLinkView.kt -->
|
||||
<string name="create_one_time_link">Создать одноразовую ссылку</string>
|
||||
<string name="one_time_link">Одноразовая ссылка</string>
|
||||
<string name="your_contact_address">Ваш SimpleX адрес</string>
|
||||
|
||||
<!-- settings - SettingsView.kt -->
|
||||
<string name="your_settings">Настройки</string>
|
||||
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
|
||||
@@ -360,17 +353,34 @@
|
||||
<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>
|
||||
@@ -398,7 +408,6 @@
|
||||
<string name="network_use_onion_hosts_no_desc_in_alert">Onion хосты не используются.</string>
|
||||
<string name="network_use_onion_hosts_required_desc_in_alert">Подключаться только к onion хостам.</string>
|
||||
<string name="appearance_settings">Интерфейс</string>
|
||||
|
||||
<!-- Address Items - UserAddressView.kt -->
|
||||
<string name="create_address">Создать адрес</string>
|
||||
<string name="delete_address__question">Удалить адрес?</string>
|
||||
@@ -406,13 +415,11 @@
|
||||
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</string>
|
||||
<string name="share_link">Поделиться\nссылкой</string>
|
||||
<string name="delete_address">Удалить\nадрес</string>
|
||||
|
||||
<!-- AcceptRequestsView.kt -->
|
||||
<string name="contact_requests">Запросы контактов</string>
|
||||
<string name="accept_requests">Принимать запросы</string>
|
||||
<string name="accept_automatically">Автоматически</string>
|
||||
<string name="section_title_welcome_message">ПРИВЕТСТВЕННОЕ СООБЩЕНИЕ</string>
|
||||
|
||||
<!-- User profile details - UserProfileView.kt -->
|
||||
<string name="display_name__field">Имя профиля:</string>
|
||||
<string name="full_name__field">"Полное имя:</string>
|
||||
@@ -420,8 +427,11 @@
|
||||
<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_contacts">Сохранить (и послать обновление контактам)</string>
|
||||
|
||||
<string name="save_preferences_question">Сохранить предпочтения?</string>
|
||||
<string name="save_and_notify_contact">Сохранить и уведомить контакт</string>
|
||||
<string name="save_and_notify_contacts">Сохранить и уведомить контакты</string>
|
||||
<string name="save_and_notify_group_members">Сохранить и уведомить членов группы</string>
|
||||
<string name="exit_without_saving">Выйти без сохранения</string>
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
|
||||
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность.</string>
|
||||
@@ -434,7 +444,6 @@
|
||||
<string name="full_name_optional__prompt">Полное имя (не обязательно)</string>
|
||||
<string name="create_profile_button">Создать</string>
|
||||
<string name="about_simplex">О SimpleX</string>
|
||||
|
||||
<!-- markdown demo - MarkdownHelpView.kt -->
|
||||
<string name="how_to_use_markdown">Как форматировать</string>
|
||||
<string name="you_can_use_markdown_to_format_messages__prompt">Вы можете форматировать сообщения:</string>
|
||||
@@ -447,7 +456,6 @@
|
||||
<string name="connect_via_link">Соединиться через ссылку</string>
|
||||
<string name="this_string_is_not_a_connection_link">Эта строка не является ссылкой-приглашением!</string>
|
||||
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Открыть в приложении</b>.</string>
|
||||
|
||||
<!-- CICallStatus -->
|
||||
<string name="callstatus_calling">входящий звонок…</string>
|
||||
<string name="callstatus_missed">пропущенный звонок</string>
|
||||
@@ -457,7 +465,6 @@
|
||||
<string name="callstatus_in_progress">активный звонок</string>
|
||||
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_error">ошибка звонка</string>
|
||||
|
||||
<!-- CallState -->
|
||||
<string name="callstate_starting">инициализация…</string>
|
||||
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
|
||||
@@ -467,7 +474,6 @@
|
||||
<string name="callstate_connecting">соединяется…</string>
|
||||
<string name="callstate_connected">соединено</string>
|
||||
<string name="callstate_ended">завершен</string>
|
||||
|
||||
<!-- SimpleXInfo -->
|
||||
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
|
||||
<string name="privacy_redefined">Более конфиденциальный</string>
|
||||
@@ -479,7 +485,6 @@
|
||||
<string name="create_your_profile">Создать профиль</string>
|
||||
<string name="make_private_connection">Добавьте контакт</string>
|
||||
<string name="how_it_works">Как это работает</string>
|
||||
|
||||
<!-- How SimpleX Works -->
|
||||
<string name="how_simplex_works">Как <xliff:g id="appName">SimpleX</xliff:g> работает</string>
|
||||
<string name="many_people_asked_how_can_it_deliver">Много пользователей спросили: <i>как <xliff:g id="appName">SimpleX</xliff:g> доставляет сообщения без идентификаторов пользователей?</i></string>
|
||||
@@ -488,10 +493,10 @@
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются <b>с двухуровневым end-to-end шифрованием</b>.</string>
|
||||
<string name="read_more_in_github">Узнайте больше из нашего GitHub репозитория.</string>
|
||||
<string name="read_more_in_github_with_link">Узнайте больше из нашего <font color="#0088ff">GitHub репозитория</font>.</string>
|
||||
|
||||
<!-- SetNotificationsMode.kt -->
|
||||
<string name="use_chat">Использовать чат</string>
|
||||
<!-- MakeConnection -->
|
||||
<string name="paste_the_link_you_received">Вставить полученную ссылку</string>
|
||||
|
||||
<!-- Call -->
|
||||
<string name="incoming_video_call">Входящий видеозвонок</string>
|
||||
<string name="incoming_audio_call">Входящий аудиозвонок</string>
|
||||
@@ -506,7 +511,6 @@
|
||||
<string name="call_already_ended">Звонок уже завершен!</string>
|
||||
<string name="icon_descr_video_call">видеозвонок</string>
|
||||
<string name="icon_descr_audio_call">аудиозвонок</string>
|
||||
|
||||
<!-- Call settings -->
|
||||
<string name="settings_audio_video_calls">Аудио- и видеозвонки</string>
|
||||
<string name="your_calls">Ваши звонки</string>
|
||||
@@ -517,12 +521,10 @@
|
||||
<string name="no_call_on_lock_screen">Выключить</string>
|
||||
<string name="your_ice_servers">Ваши ICE серверы</string>
|
||||
<string name="webrtc_ice_servers">WebRTC ICE серверы</string>
|
||||
|
||||
<!-- Call Lock Screen -->
|
||||
<string name="open_simplex_chat_to_accept_call">Откройте <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\nчтобы принять звонок</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">Вы можете разрешить принимать звонки на экране блокировки через Настройки.</string>
|
||||
<string name="open_verb">Открыть</string>
|
||||
|
||||
<!-- Call overlay -->
|
||||
<string name="status_e2e_encrypted">e2e зашифровано</string>
|
||||
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
|
||||
@@ -538,7 +540,6 @@
|
||||
<string name="icon_descr_speaker_off">Выключить спикер</string>
|
||||
<string name="icon_descr_speaker_on">Включить спикер</string>
|
||||
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
|
||||
|
||||
<!-- Call items -->
|
||||
<string name="icon_descr_call_pending_sent">Входящий звонок</string>
|
||||
<string name="icon_descr_call_missed">Пропущенный звонок</string>
|
||||
@@ -547,7 +548,6 @@
|
||||
<string name="icon_descr_call_progress">Текущий звонок</string>
|
||||
<string name="icon_descr_call_ended">Звонок завершен</string>
|
||||
<string name="answer_call">Принять звонок</string>
|
||||
|
||||
<!-- Message integrity -->
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> пропущенных сообщений</string>
|
||||
<string name="integrity_msg_bad_hash">ошибка хэш сообщения</string>
|
||||
@@ -555,14 +555,14 @@
|
||||
<string name="integrity_msg_duplicate">повторное сообщение</string>
|
||||
<string name="alert_title_skipped_messages">Пропущенные сообщения</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</string>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<string name="full_backup">Резервная копия данных</string>
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">ВЫ</string>
|
||||
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
|
||||
@@ -579,7 +579,6 @@
|
||||
<string name="settings_section_title_messages">СООБЩЕНИЯ</string>
|
||||
<string name="settings_section_title_calls">ЗВОНКИ</string>
|
||||
<string name="settings_section_title_incognito">Режим Инкогнито</string>
|
||||
|
||||
<!-- DatabaseView.kt -->
|
||||
<string name="your_chat_database">База данных</string>
|
||||
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
|
||||
@@ -629,7 +628,6 @@
|
||||
<string name="enable_automatic_deletion_message">Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут.</string>
|
||||
<string name="delete_messages">Удалить сообщения</string>
|
||||
<string name="error_changing_message_deletion">Ошибка при изменении настройки</string>
|
||||
|
||||
<!-- DatabaseEncryptionView.kt -->
|
||||
<string name="save_passphrase_in_keychain">Сохранить пароль в Keystore</string>
|
||||
<string name="database_encrypted">База данных зашифрована!</string>
|
||||
@@ -658,7 +656,6 @@
|
||||
<string name="database_passphrase_will_be_updated">Пароль базы данных будет изменен.</string>
|
||||
<string name="store_passphrase_securely">Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</string>
|
||||
<string name="store_passphrase_securely_without_recover">Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.</string>
|
||||
|
||||
<!-- DatabaseErrorView.kt -->
|
||||
<string name="wrong_passphrase">Неправильный пароль базы данных</string>
|
||||
<string name="encrypted_database">База данных зашифрована</string>
|
||||
@@ -682,11 +679,10 @@
|
||||
<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>
|
||||
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Вы можете запустить чат через Настройки приложения или перезапустив приложение.</string>
|
||||
|
||||
<!-- ChatArchiveView.kt -->
|
||||
<string name="chat_archive_header">Архив чата</string>
|
||||
<string name="chat_archive_section">АРХИВ ЧАТА</string>
|
||||
@@ -694,7 +690,6 @@
|
||||
<string name="delete_archive">Удалить архив</string>
|
||||
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
|
||||
<string name="delete_chat_archive_question">Удалить архив чата?</string>
|
||||
|
||||
<!-- Groups -->
|
||||
<string name="group_invitation_item_description">приглашение в группу <xliff:g id="group_name">%1$s</xliff:g></string>
|
||||
<string name="join_group_question">Вступить в группу?</string>
|
||||
@@ -714,7 +709,6 @@
|
||||
<string name="alert_message_no_group">Эта группа больше не существует.</string>
|
||||
<string name="alert_title_cant_invite_contacts">Нельзя пригласить контакты!</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено</string>
|
||||
|
||||
<!-- CIGroupInvitationView.kt -->
|
||||
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
|
||||
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
|
||||
@@ -723,7 +717,6 @@
|
||||
<string name="you_joined_this_group">Вы вступили в эту группу</string>
|
||||
<string name="you_rejected_group_invitation">Вы отклонили приглашение в группу</string>
|
||||
<string name="group_invitation_expired">Приглашение в группу истекло</string>
|
||||
|
||||
<!-- Group event chat items -->
|
||||
<string name="rcv_group_event_member_added">пригласил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="rcv_group_event_member_connected">соединен(а)</string>
|
||||
@@ -740,7 +733,6 @@
|
||||
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="snd_group_event_user_left">вы покинули группу</string>
|
||||
<string name="snd_group_event_group_profile_updated">профиль группы обновлен</string>
|
||||
|
||||
<!-- Conn event chat items -->
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">поменял(а) адрес для вас</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">смена адреса…</string>
|
||||
@@ -748,12 +740,10 @@
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">смена адреса для %s…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">вы поменяли адрес</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">смена адреса…</string>
|
||||
|
||||
<!-- GroupMemberRole -->
|
||||
<string name="group_member_role_member">член группы</string>
|
||||
<string name="group_member_role_admin">админ</string>
|
||||
<string name="group_member_role_owner">владелец</string>
|
||||
|
||||
<!-- GroupMemberStatus -->
|
||||
<string name="group_member_status_removed">удален(а)</string>
|
||||
<string name="group_member_status_left">покинул(а)</string>
|
||||
@@ -766,21 +756,20 @@
|
||||
<string name="group_member_status_connected">соединен(а)</string>
|
||||
<string name="group_member_status_complete">соединение завершено</string>
|
||||
<string name="group_member_status_creator">создатель</string>
|
||||
|
||||
<string name="group_member_status_connecting">соединяется</string>
|
||||
|
||||
<!-- AddGroupMembersView.kt -->
|
||||
<string name="no_contacts_to_add">Нет контактов для добавления</string>
|
||||
<string name="new_member_role">Роль члена группы</string>
|
||||
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
|
||||
<string name="invite_to_group_button">Пригласить в группу</string>
|
||||
<string name="skip_inviting_button">Не приглашать членов</string>
|
||||
<string name="select_contacts">Выберите контакты</string>
|
||||
<string name="icon_descr_contact_checked">Контакт выбран</string>
|
||||
<string name="clear_contacts_selection_button">Очистить</string>
|
||||
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
|
||||
<string name="no_contacts_selected">Контакты не выбраны</string>
|
||||
<string name="invite_prohibited">Нельзя пригласить контакт!</string>
|
||||
<string name="invite_prohibited_description">Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль</string>
|
||||
|
||||
<!-- GroupChatInfoView.kt -->
|
||||
<string name="button_add_members">Пригласить членов группы</string>
|
||||
<string name="group_info_section_title_num_members">ЧЛЕНОВ ГРУППЫ: <xliff:g id="num_members">%1$s</xliff:g></string>
|
||||
@@ -799,12 +788,11 @@
|
||||
<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>
|
||||
<string name="info_row_local_name">Локальное имя</string>
|
||||
<string name="info_row_database_id">ID базы данных</string>
|
||||
|
||||
<!-- GroupMemberInfoView.kt -->
|
||||
<string name="button_remove_member">Удалить члена группы</string>
|
||||
<string name="button_send_direct_message">Отправить сообщение</string>
|
||||
@@ -824,14 +812,12 @@
|
||||
<string name="info_row_connection">Соединение</string>
|
||||
<string name="conn_level_desc_direct">прямое</string>
|
||||
<string name="conn_level_desc_indirect">непрямое (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
|
||||
<!-- ConnectionStats -->
|
||||
<string name="conn_stats_section_title_servers">СЕРВЕРЫ</string>
|
||||
<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>
|
||||
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только членам.</string>
|
||||
@@ -839,12 +825,10 @@
|
||||
<string name="group_full_name_field">Полное имя:</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы</string>
|
||||
<string name="group_main_profile_sent">Ваш профиль чата будет отправлен членам группы</string>
|
||||
|
||||
<!-- GroupProfileView.kt -->
|
||||
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах членов, а не на серверах.</string>
|
||||
<string name="save_group_profile">Сохранить профиль группы</string>
|
||||
<string name="error_saving_group_profile">Ошибка при сохранении профиля группы</string>
|
||||
|
||||
<!-- AdvancedNetworkSettings.kt -->
|
||||
<string name="network_options_reset_to_defaults">Сбросить настройки</string>
|
||||
<string name="network_option_seconds_label">сек</string>
|
||||
@@ -857,26 +841,127 @@
|
||||
<string name="update_network_settings_question">Обновить настройки сети?</string>
|
||||
<string name="updating_settings_will_reconnect_client_to_all_servers">Обновление настроек приведет к переподключению клиента ко всем серверам.</string>
|
||||
<string name="update_network_settings_confirmation">Обновить</string>
|
||||
|
||||
<!-- Incognito mode -->
|
||||
<string name="incognito">Инкогнито</string>
|
||||
<string name="incognito_random_profile">Случайный профиль</string>
|
||||
<string name="incognito_random_profile_description">Вашему контакту будет отправлен случайный профиль</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль</string>
|
||||
|
||||
<string name="incognito_info_protects">Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</string>
|
||||
<string name="incognito_info_allows">Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</string>
|
||||
<string name="incognito_info_share">Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</string>
|
||||
<string name="incognito_info_find">Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.</string>
|
||||
|
||||
<!-- Default themes -->
|
||||
<string name="theme_system">Системная</string>
|
||||
<string name="theme_light">Светлая</string>
|
||||
<string name="theme_dark">Темная</string>
|
||||
|
||||
<!-- Appearance.kt -->
|
||||
<string name="theme">Тема</string>
|
||||
<string name="save_color">Сохранить цвет</string>
|
||||
<string name="reset_color">Сбросить цвета</string>
|
||||
<string name="color_primary">Акцент</string>
|
||||
</resources>
|
||||
<!-- 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="set_group_preferences">Предпочтения группы</string>
|
||||
<string name="your_preferences">Ваши предпочтения</string>
|
||||
<string name="direct_messages">Прямые сообщения</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_direct_messages">Разрешить посылать прямые сообщения членам группы.</string>
|
||||
<string name="prohibit_direct_messages">Запретить посылать прямые сообщения членам группы.</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_send_dms">Члены группы могут посылать прямые сообщения.</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Прямые сообщения между членами группы запрещены.</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>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>Минимальный расход батареи</b>. Вы получите уведомления только когда приложение запущено, без фонового сервиса.</string>
|
||||
<string name="onboarding_notifications_mode_title">Уведомления</string>
|
||||
<string name="onboarding_notifications_mode_off">Когда приложение запущено</string>
|
||||
<string name="onboarding_notifications_mode_periodic">Периодически</string>
|
||||
<string name="onboarding_notifications_mode_service">Мгновенно</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>Больше расход батареи</b>! Фоновый сервис постоянно запущен - уведомления будут показаны как только есть новые сообщения.</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>Меньше расход батареи</b>. Фоновый сервис проверяет новые сообщения каждые 10 минут. Вы можете пропустить звонки и срочные сообщения.</string>
|
||||
<string name="onboarding_notifications_mode_subtitle">Можно изменить позже в настройках.</string>
|
||||
<string name="live">LIVE</string>
|
||||
<string name="send_live_message">Отправить живое сообщение</string>
|
||||
<string name="live_message">Живое сообщение!</string>
|
||||
<string name="send_verb">Отправить</string>
|
||||
<string name="scan_code_from_contacts_app">Сканируйте код безопасности из приложения контакта.</string>
|
||||
<string name="delete_after">Удалять через</string>
|
||||
<string name="ttl_sec">%d сек</string>
|
||||
<string name="ttl_s">%dс</string>
|
||||
<string name="ttl_min">%d мин</string>
|
||||
<string name="ttl_month">%d мес.</string>
|
||||
<string name="ttl_months">%d мес.</string>
|
||||
<string name="ttl_m">%dм</string>
|
||||
<string name="ttl_mth">%dмес</string>
|
||||
<string name="ttl_hour">%d час</string>
|
||||
<string name="ttl_hours">%d ч.</string>
|
||||
<string name="ttl_h">%dч</string>
|
||||
<string name="ttl_day">%d день</string>
|
||||
<string name="ttl_week">%d нед.</string>
|
||||
<string name="timed_messages">Исчезающие сообщения</string>
|
||||
<string name="view_security_code">Показать код безопасности</string>
|
||||
<string name="verify_security_code">Подтвердить код безопасности</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Вы и ваш контакт можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_you_can_send_disappearing">Только вы можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Только ваш контакт может отправлять исчезающие сообщения.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Исчезающие сообщения запрещены в этом чате.</string>
|
||||
<string name="allow_to_send_disappearing">Разрешить посылать исчезающие сообщения.</string>
|
||||
<string name="contact_developers">Пожалуйста, обновите приложение и свяжитесь с разработчиками.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Разрешить вашим контактам отправлять исчезающие сообщения.</string>
|
||||
<string name="failed_to_parse_chat_title">Не удалось открыть чат</string>
|
||||
<string name="failed_to_parse_chats_title">Не удалось открыть чаты</string>
|
||||
<string name="incorrect_code">Неправильный код безопасности!</string>
|
||||
<string name="scan_code">Сканировать код</string>
|
||||
<string name="send_live_message_desc">Отправить живое сообщение — оно будет обновляться для получателей по мере того, как вы его вводите</string>
|
||||
<string name="create_group_link">Создать ссылку группы</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Запретить отправлять исчезающие сообщения.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Исчезающие сообщения запрещены в этой группе.</string>
|
||||
<string name="ttl_w">%dнед</string>
|
||||
<string name="ttl_d">%dд</string>
|
||||
<string name="ttl_weeks">%d нед.</string>
|
||||
<string name="ttl_days">%d дней</string>
|
||||
<string name="to_verify_compare">Чтобы подтвердить безопасность end-to-end шифрования с вашим контактом сравните (или сканируйте) код на ваших устройствах.</string>
|
||||
<string name="is_verified">%s подтверждён</string>
|
||||
<string name="is_not_verified">%s не подтверждён</string>
|
||||
<string name="security_code">Код безопасности</string>
|
||||
<string name="mark_code_verified">Подтвердить</string>
|
||||
<string name="clear_verification">Сбросить подтверждение</string>
|
||||
<string name="allow_disappearing_messages_only_if">Разрешить исчезающие сообщения, только если ваш контакт разрешает их вам.</string>
|
||||
<string name="prohibit_sending_disappearing">Запретить посылать исчезающие сообщения.</string>
|
||||
<string name="group_members_can_send_disappearing">Члены группы могут посылать исчезающие сообщения.</string>
|
||||
</resources>
|
||||
@@ -21,11 +21,13 @@
|
||||
|
||||
<!-- Item Content - ChatModel.kt -->
|
||||
<string name="deleted_description">deleted</string>
|
||||
<string name="marked_deleted_description">marked deleted</string>
|
||||
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
|
||||
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
|
||||
<string name="sender_you_pronoun">you</string>
|
||||
<string name="unknown_message_format">unknown message format</string>
|
||||
<string name="invalid_message_format">invalid message format</string>
|
||||
<string name="live">LIVE</string>
|
||||
|
||||
<!-- PendingContactConnection - ChatModel.kt -->
|
||||
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
@@ -41,15 +43,29 @@
|
||||
<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>
|
||||
<string name="error_setting_network_config">Error updating network configuration</string>
|
||||
<string name="failed_to_parse_chat_title">Failed to load chat</string>
|
||||
<string name="failed_to_parse_chats_title">Failed to load chats</string>
|
||||
<string name="contact_developers">Please update the app and contact developers.</string>
|
||||
|
||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||
<string name="connection_timeout">Connection timeout</string>
|
||||
<string name="connection_error">Connection error</string>
|
||||
<string name="network_error_desc">Please check your network connection and try again.</string>
|
||||
<string name="network_error_desc">Please check your network connection with <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> and try again.</string>
|
||||
<string name="error_sending_message">Error sending message</string>
|
||||
<string name="error_adding_members">Error adding member(s)</string>
|
||||
<string name="error_joining_group">Error joining group</string>
|
||||
@@ -70,6 +86,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>
|
||||
@@ -95,7 +119,6 @@
|
||||
<!-- Notification channels -->
|
||||
<string name="ntf_channel_messages">SimpleX Chat messages</string>
|
||||
<string name="ntf_channel_calls">SimpleX Chat calls</string>
|
||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat calls (lock screen)</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="settings_notifications_mode_title">Notification service</string>
|
||||
@@ -148,9 +171,13 @@
|
||||
<string name="save_verb">Save</string>
|
||||
<string name="edit_verb">Edit</string>
|
||||
<string name="delete_verb">Delete</string>
|
||||
<string name="reveal_verb">Reveal</string>
|
||||
<string name="hide_verb">Hide</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>
|
||||
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
|
||||
<string name="for_me_only">Delete for me</string>
|
||||
<string name="for_everybody">For everyone</string>
|
||||
|
||||
<!-- CIMetaView.kt -->
|
||||
@@ -208,6 +235,11 @@
|
||||
<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_with_duration">Voice message (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||
<string name="voice_message_send_text">Voice message…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Notifications</string>
|
||||
|
||||
@@ -222,14 +254,27 @@
|
||||
<string name="icon_descr_server_status_pending">Pending</string>
|
||||
<string name="switch_receiving_address_question">Switch receiving address?</string>
|
||||
<string name="switch_receiving_address_desc">This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member).</string>
|
||||
<string name="view_security_code">View security code</string>
|
||||
<string name="verify_security_code">Verify security code</string>
|
||||
|
||||
<!-- 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>
|
||||
<string name="send_live_message">Send live message</string>
|
||||
<string name="live_message">Live message!</string>
|
||||
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
|
||||
<string name="send_verb">Send</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>
|
||||
@@ -350,6 +395,19 @@
|
||||
<string name="one_time_link">One-time invitation link</string>
|
||||
<string name="your_contact_address">Your contact address</string>
|
||||
|
||||
<!-- ScanCodeView.kt -->
|
||||
<string name="scan_code">Scan code</string>
|
||||
<string name="incorrect_code">Incorrect security code!</string>
|
||||
<string name="scan_code_from_contacts_app">Scan security code from your contact\'s app.</string>
|
||||
|
||||
<!-- VerifyCodeView.kt -->
|
||||
<string name="security_code">Security code</string>
|
||||
<string name="mark_code_verified">Mark verified</string>
|
||||
<string name="clear_verification">Clear verification</string>
|
||||
<string name="to_verify_compare">To verify end-to-end encryption with your contact compare (or scan) the code on your devices.</string>
|
||||
<string name="is_verified">%s is verified</string>
|
||||
<string name="is_not_verified">%s is not verified</string>
|
||||
|
||||
<!-- settings - SettingsView.kt -->
|
||||
<string name="your_settings">Your settings</string>
|
||||
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
|
||||
@@ -363,17 +421,34 @@
|
||||
<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>
|
||||
@@ -423,7 +498,11 @@
|
||||
<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_contacts">Save (and notify contacts)</string>
|
||||
<string name="save_preferences_question">Save preferences?</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>
|
||||
<string name="exit_without_saving">Exit without saving</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">You control your chat!</string>
|
||||
@@ -489,6 +568,17 @@
|
||||
<string name="read_more_in_github">Read more in our GitHub repository.</string>
|
||||
<string name="read_more_in_github_with_link">Read more in our <font color="#0088ff">GitHub repository</font>.</string>
|
||||
|
||||
<!-- SetNotificationsMode.kt -->
|
||||
<string name="use_chat">Use chat</string>
|
||||
<string name="onboarding_notifications_mode_title">Private notifications</string>
|
||||
<string name="onboarding_notifications_mode_subtitle">It can be changed later via settings.</string>
|
||||
<string name="onboarding_notifications_mode_off">When app is running</string>
|
||||
<string name="onboarding_notifications_mode_periodic">Periodic</string>
|
||||
<string name="onboarding_notifications_mode_service">Instant</string>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>Best for battery</b>. You will receive notifications only when the app is running, background service will NOT be used.</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>Good for battery</b>. Background service checks for new messages every 10 minutes. You may miss calls and urgent messages.</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>Uses more battery</b>! Background service is always running – notifications will be shown as soon as the messages are available.</string>
|
||||
|
||||
<!-- MakeConnection -->
|
||||
<string name="paste_the_link_you_received">Paste received link</string>
|
||||
|
||||
@@ -559,9 +649,11 @@
|
||||
<!-- 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>
|
||||
<string name="full_backup">App data backup</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">YOU</string>
|
||||
@@ -682,6 +774,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>
|
||||
@@ -774,6 +867,8 @@
|
||||
<string name="new_member_role">New member role</string>
|
||||
<string name="icon_descr_expand_role">Expand role selection</string>
|
||||
<string name="invite_to_group_button">Invite to group</string>
|
||||
<string name="skip_inviting_button">Skip inviting members</string>
|
||||
<string name="select_contacts">Select contacts</string>
|
||||
<string name="icon_descr_contact_checked">Contact checked</string>
|
||||
<string name="clear_contacts_selection_button">Clear</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
|
||||
@@ -792,6 +887,7 @@
|
||||
<string name="button_leave_group">Leave group</string>
|
||||
<string name="button_edit_group_profile">Edit group profile</string>
|
||||
<string name="group_link">Group link</string>
|
||||
<string name="create_group_link">Create group link</string>
|
||||
<string name="button_create_group_link">Create link</string>
|
||||
<string name="delete_link_question">Delete link?</string>
|
||||
<string name="delete_link">Delete link</string>
|
||||
@@ -799,6 +895,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>
|
||||
@@ -830,7 +927,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>
|
||||
@@ -880,4 +977,108 @@
|
||||
<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="set_group_preferences">Set group preferences</string>
|
||||
<string name="your_preferences">Your preferences</string>
|
||||
<string name="timed_messages">Disappearing messages</string>
|
||||
<string name="direct_messages">Direct messages</string>
|
||||
<string name="full_deletion">Delete for everyone</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="accept_feature">Accept</string>
|
||||
<string name="accept_feature_set_1_day">Set 1 day</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Allow your contacts to send disappearing messages.</string>
|
||||
<string name="allow_disappearing_messages_only_if">Allow disappearing messages only if your contact allows them.</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Prohibit sending disappearing messages.</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_contact_can_send_disappearing">Both you and your contact can send disappearing messages.</string>
|
||||
<string name="only_you_can_send_disappearing">Only you can send disappearing messages.</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Only your contact can send disappearing messages.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Disappearing messages are prohibited in this chat.</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_send_disappearing">Allow to send disappearing messages.</string>
|
||||
<string name="prohibit_sending_disappearing">Prohibit sending disappearing messages.</string>
|
||||
<string name="allow_direct_messages">Allow sending direct messages to members.</string>
|
||||
<string name="prohibit_direct_messages">Prohibit sending direct messages to members.</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_send_disappearing">Group members can send disappearing messages.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Disappearing messages are prohibited in this group.</string>
|
||||
<string name="group_members_can_send_dms">Group members can send direct messages.</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Direct messages between members are prohibited in this group.</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 group.</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 group.</string>
|
||||
<string name="delete_after">Delete after</string>
|
||||
<string name="ttl_sec">%d sec</string>
|
||||
<string name="ttl_s">%ds</string>
|
||||
<string name="ttl_min">%d min</string>
|
||||
<string name="ttl_month">%d month</string>
|
||||
<string name="ttl_months">%d months</string>
|
||||
<string name="ttl_m">%dm</string>
|
||||
<string name="ttl_mth">%dmth</string>
|
||||
<string name="ttl_hour">%d hour</string>
|
||||
<string name="ttl_hours">%d hours</string>
|
||||
<string name="ttl_h">%dh</string>
|
||||
<string name="ttl_day">%d day</string>
|
||||
<string name="ttl_days">%d days</string>
|
||||
<string name="ttl_d">%dd</string>
|
||||
<string name="ttl_week">%d week</string>
|
||||
<string name="ttl_weeks">%d weeks</string>
|
||||
<string name="ttl_w">%dw</string>
|
||||
|
||||
<!-- WhatsNewView.kt -->
|
||||
<string name="whats_new">What\'s new</string>
|
||||
<string name="new_in_version">New in %s</string>
|
||||
<string name="v4_2_security_assessment">Security assessment</string>
|
||||
<string name="v4_2_security_assessment_desc">SimpleX Chat security was audited by Trail of Bits.</string>
|
||||
<string name="v4_2_group_links">Group links</string>
|
||||
<string name="v4_2_group_links_desc">Admins can create the links to join groups.</string>
|
||||
<string name="v4_2_auto_accept_contact_requests">Auto-accept contact requests</string>
|
||||
<string name="v4_2_auto_accept_contact_requests_desc">With optional welcome message.</string>
|
||||
<string name="v4_3_voice_messages">Voice messages</string>
|
||||
<string name="v4_3_voice_messages_desc">Max 40 seconds, received instantly.</string>
|
||||
<string name="v4_3_irreversible_message_deletion">Irreversible message deletion</string>
|
||||
<string name="v4_3_irreversible_message_deletion_desc">Your contacts can allow full message deletion.</string>
|
||||
<string name="v4_3_improved_server_configuration">Improved server configuration</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">Add servers by scanning QR codes.</string>
|
||||
<string name="v4_3_improved_privacy_and_security">Improved privacy and security</string>
|
||||
<string name="v4_3_improved_privacy_and_security_desc">Hide app screen in the recent apps.</string>
|
||||
<string name="v4_4_disappearing_messages">Disappearing messages</string>
|
||||
<string name="v4_4_disappearing_messages_desc">Sent messages will be deleted after set time.</string>
|
||||
<string name="v4_4_live_messages">Live messages</string>
|
||||
<string name="v4_4_live_messages_desc">Recipients see updates as you type them.</string>
|
||||
<string name="v4_4_verify_connection_security">Verify connection security</string>
|
||||
<string name="v4_4_verify_connection_security_desc">Compare security codes with your contacts.</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,6 +17,9 @@ 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
|
||||
@State private var showWhatsNew = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -29,7 +32,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,16 +49,29 @@ 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
|
||||
alertManager.showAlert(laNoticeAlert())
|
||||
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
showWhatsNew = shouldShowWhatsNew()
|
||||
}
|
||||
}
|
||||
prefShowLANotice = true
|
||||
}
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
}
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
ActiveCallView(call: call)
|
||||
}
|
||||
@@ -68,9 +84,9 @@ struct ContentView: View {
|
||||
userAuthorized = true
|
||||
} else {
|
||||
dismissAllSheets(animated: false) {
|
||||
chatModel.chatId = nil
|
||||
justAuthenticate()
|
||||
}
|
||||
chatModel.chatId = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,14 +132,14 @@ struct ContentView: View {
|
||||
func notificationAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Notifications are disabled!"),
|
||||
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
|
||||
primaryButton: .default(Text("Open Settings")) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
|
||||
primaryButton: .default(Text("Open Settings")) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import UIKit
|
||||
//import UIKit
|
||||
|
||||
let s = """
|
||||
{
|
||||
@@ -15,6 +15,6 @@ let s = """
|
||||
}
|
||||
"""
|
||||
//let s = "\"2022-04-24T11:59:23.703162Z\""
|
||||
let json = getJSONDecoder()
|
||||
let d = s.data(using: .utf8)!
|
||||
print (try! json.decode(ChatInfo.self, from: d))
|
||||
//let json = getJSONDecoder()
|
||||
//let d = s.data(using: .utf8)!
|
||||
//print (try! json.decode(ChatInfo.self, from: d))
|
||||
|
||||
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> = [:]
|
||||
@@ -98,7 +101,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateContact(_ contact: Contact) {
|
||||
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact && !contact.viaGroupLink)
|
||||
updateChat(.direct(contact: contact), addMissing: contact.directOrUsed)
|
||||
}
|
||||
|
||||
func updateGroup(_ groupInfo: GroupInfo) {
|
||||
@@ -219,8 +222,10 @@ final class ChatModel: ObservableObject {
|
||||
withAnimation(.default) {
|
||||
self.reversedChatItems[i] = cItem
|
||||
self.reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
// arrives earlier than the response from API, and item remains without tick
|
||||
if case .sndNew = cItem.meta.itemStatus {
|
||||
self.reversedChatItems[i].meta = ci.meta
|
||||
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -231,16 +236,19 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if cItem.isRcvNew {
|
||||
decreaseUnreadCounter(cInfo)
|
||||
}
|
||||
// update previews
|
||||
if let chat = getChat(cInfo.id) {
|
||||
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
|
||||
chat.chatItems = [cItem]
|
||||
chat.chatItems = [ChatItem.deletedItemDummy()]
|
||||
}
|
||||
}
|
||||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if reversedChatItems[i].isRcvNew() == true {
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
NtfManager.shared.decNtfBadgeCount()
|
||||
}
|
||||
_ = withAnimation {
|
||||
@@ -281,10 +289,7 @@ final class ChatModel: ObservableObject {
|
||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||
var j = i
|
||||
while j < reversedChatItems.count {
|
||||
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[j].viewTimestamp = .now
|
||||
}
|
||||
markChatItemRead_(j)
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
@@ -337,14 +342,28 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
markChatItemRead_(i)
|
||||
}
|
||||
}
|
||||
|
||||
private func markChatItemRead_(_ i: Int) {
|
||||
let meta = reversedChatItems[i].meta
|
||||
if case .rcvNew = meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
||||
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[j].viewTimestamp = .now
|
||||
}
|
||||
}
|
||||
|
||||
func totalUnreadCount() -> Int {
|
||||
@@ -413,7 +432,7 @@ final class ChatModel: ObservableObject {
|
||||
var unreadBelow = 0
|
||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
||||
totalBelow += 1
|
||||
if reversedChatItems[i].isRcvNew() {
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
unreadBelow += 1
|
||||
}
|
||||
i += 1
|
||||
@@ -501,4 +520,6 @@ final class Chat: ObservableObject, Identifiable {
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
|
||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||
|
||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
}
|
||||
|
||||
189
apps/ios/Shared/Model/ImageUtils.swift
Normal file
189
apps/ios/Shared/Model/ImageUtils.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// ImageUtils.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 24/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
func getLoadedFilePath(_ file: CIFile?) -> String? {
|
||||
if let fileName = getLoadedFileName(file) {
|
||||
return getAppFilePath(fileName).path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedFileName(_ file: CIFile?) -> String? {
|
||||
if let file = file,
|
||||
file.loaded,
|
||||
let fileName = file.filePath {
|
||||
return fileName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
do {
|
||||
let data = try Data(contentsOf: filePath)
|
||||
let img = UIImage(data: data)
|
||||
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
return img
|
||||
} catch {
|
||||
return UIImage(contentsOfFile: loadedFilePath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAnimImage(_ image: UIImage) -> String? {
|
||||
let fileName = generateNewFileName("IMG", "gif")
|
||||
guard let imageData = image.imageData else { return nil }
|
||||
return saveFile(imageData, fileName)
|
||||
}
|
||||
|
||||
func saveImage(_ uiImage: UIImage) -> String? {
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) {
|
||||
let ext = imageHasAlpha(uiImage) ? "png" : "jpg"
|
||||
let fileName = generateNewFileName("IMG", ext)
|
||||
return saveFile(imageDataResized, fileName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
let size = image.size
|
||||
let side = min(size.width, size.height)
|
||||
let newSize = CGSize(width: side, height: side)
|
||||
var origin = CGPoint.zero
|
||||
if size.width > side {
|
||||
origin.x -= (size.width - side) / 2
|
||||
} else if size.height > side {
|
||||
origin.y -= (size.height - side) / 2
|
||||
}
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
|
||||
}
|
||||
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
|
||||
var img = image
|
||||
let usePng = imageHasAlpha(image)
|
||||
var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
var dataSize = data?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
dataSize = data?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToDataSize final \(dataSize)")
|
||||
return data
|
||||
}
|
||||
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
var dataSize = str?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
str = compressImageStr(img)
|
||||
dataSize = str?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToStrSize final \(dataSize)")
|
||||
return str
|
||||
}
|
||||
|
||||
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
|
||||
let ext = imageHasAlpha(image) ? "png" : "jpg"
|
||||
if let data = imageHasAlpha(image) ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
|
||||
return "data:image/\(ext);base64,\(data.base64EncodedString())"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
|
||||
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
|
||||
let bounds = CGRect(origin: .zero, size: newSize)
|
||||
return resizeImage(image, newBounds: bounds, drawIn: bounds)
|
||||
}
|
||||
|
||||
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0
|
||||
format.opaque = !imageHasAlpha(image)
|
||||
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
|
||||
image.draw(in: drawIn)
|
||||
}
|
||||
}
|
||||
|
||||
func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
let alpha = img.cgImage?.alphaInfo
|
||||
return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly
|
||||
}
|
||||
|
||||
func saveFileFromURL(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
do {
|
||||
let fileData = try Data(contentsOf: url)
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
savedFile = saveFile(fileData, fileName)
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
}
|
||||
} else {
|
||||
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
|
||||
savedFile = nil
|
||||
}
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
||||
let fileName = uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
|
||||
return fileName
|
||||
}
|
||||
|
||||
private func uniqueCombine(_ fileName: String) -> String {
|
||||
func tryCombine(_ fileName: String, _ n: Int) -> String {
|
||||
let ns = fileName as NSString
|
||||
let name = ns.deletingPathExtension
|
||||
let ext = ns.pathExtension
|
||||
let suffix = (n == 0) ? "" : "_\(n)"
|
||||
let f = "\(name)\(suffix).\(ext)"
|
||||
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
||||
}
|
||||
return tryCombine(fileName, 0)
|
||||
}
|
||||
|
||||
private var tsFormatter: DateFormatter?
|
||||
|
||||
private func getTimestamp() -> String {
|
||||
var df: DateFormatter
|
||||
if let tsFormatter = tsFormatter {
|
||||
df = tsFormatter
|
||||
} else {
|
||||
df = DateFormatter()
|
||||
df.dateFormat = "yyyyMMdd_HHmmss"
|
||||
df.locale = Locale(identifier: "US")
|
||||
tsFormatter = df
|
||||
}
|
||||
return df.string(from: Date())
|
||||
}
|
||||
|
||||
func dropImagePrefix(_ s: String) -> String {
|
||||
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
|
||||
}
|
||||
|
||||
private func dropPrefix(_ s: String, _ prefix: String) -> String {
|
||||
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
||||
}
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,9 +219,9 @@ func loadChat(chat: Chat, search: String = "") {
|
||||
}
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async -> ChatItem? {
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
|
||||
let chatModel = ChatModel.shared
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
|
||||
let r: ChatResponse
|
||||
if type == .direct {
|
||||
var cItem: ChatItem!
|
||||
@@ -255,15 +255,15 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
||||
)
|
||||
}
|
||||
|
||||
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay)
|
||||
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay)
|
||||
if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem {
|
||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem }
|
||||
if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -345,14 +356,14 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
|
||||
func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
|
||||
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
|
||||
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
|
||||
if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) {
|
||||
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) {
|
||||
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) }
|
||||
throw r
|
||||
}
|
||||
@@ -365,6 +376,32 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws
|
||||
try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
}
|
||||
|
||||
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
|
||||
let r = await chatSendCmd(.apiGetContactCode(contactId: contactId))
|
||||
if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
|
||||
let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? {
|
||||
let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
|
||||
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
|
||||
logger.error("apiVerifyContact error: \(String(describing: r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? {
|
||||
let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
|
||||
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
|
||||
logger.error("apiVerifyGroupMember error: \(String(describing: r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiAddContact() async -> String? {
|
||||
let r = await chatSendCmd(.addContact, bgTask: false)
|
||||
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
|
||||
@@ -476,6 +513,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 }
|
||||
@@ -570,10 +613,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
|
||||
}
|
||||
@@ -581,16 +629,16 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Bool {
|
||||
let am = AlertManager.shared
|
||||
switch r {
|
||||
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
|
||||
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
am.showAlertMsg(
|
||||
title: "Connection timeout",
|
||||
message: "Please check your network connection and try again."
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
return true
|
||||
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
|
||||
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
|
||||
am.showAlertMsg(
|
||||
title: "Connection error",
|
||||
message: "Please check your network connection and try again."
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
return true
|
||||
default:
|
||||
@@ -760,6 +808,12 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
|
||||
return []
|
||||
}
|
||||
|
||||
func apiListMembersSync(_ groupId: Int64) -> [GroupMember] {
|
||||
let r = chatSendCmdSync(.apiListMembers(groupId: groupId))
|
||||
if case let .groupMembers(group) = r { return group.members }
|
||||
return []
|
||||
}
|
||||
|
||||
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
|
||||
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
|
||||
return ChatModel.shared.chats
|
||||
@@ -826,7 +880,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) }
|
||||
@@ -895,7 +949,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .contactConnectionDeleted(connection):
|
||||
m.removeChat(connection.id)
|
||||
case let .contactConnected(contact, _):
|
||||
if !contact.viaGroupLink {
|
||||
if contact.directOrUsed {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
@@ -903,7 +957,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
NtfManager.shared.notifyContactConnected(contact)
|
||||
}
|
||||
case let .contactConnecting(contact):
|
||||
if !contact.viaGroupLink {
|
||||
if contact.directOrUsed {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
@@ -950,22 +1004,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
m.addChatItem(cInfo, cItem)
|
||||
if case .image = cItem.content.msgContent,
|
||||
let file = cItem.file,
|
||||
file.fileSize <= maxImageSize,
|
||||
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
if let file = cItem.file,
|
||||
let mc = cItem.content.msgContent,
|
||||
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV {
|
||||
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
if (mc.isImage && acceptImages)
|
||||
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId) // TODO check inlineFileMode != IFMSent
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cItem.chatDir.sent && !cItem.isCall() && !cItem.isMutedMemberEvent {
|
||||
if cItem.showNotification {
|
||||
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 {
|
||||
@@ -980,14 +1037,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
case let .chatItemUpdated(aChatItem):
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
case let .chatItemDeleted(_, toChatItem):
|
||||
let cInfo = toChatItem.chatInfo
|
||||
let cItem = toChatItem.chatItem
|
||||
if cItem.meta.itemDeleted {
|
||||
m.removeChatItem(cInfo, cItem)
|
||||
case let .chatItemDeleted(deletedChatItem, toChatItem, _):
|
||||
if let toChatItem = toChatItem {
|
||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||
} else {
|
||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||
_ = m.upsertChatItem(cInfo, cItem)
|
||||
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
|
||||
}
|
||||
case let .receivedGroupInvitation(groupInfo, _, _):
|
||||
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
|
||||
@@ -1025,9 +1079,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)
|
||||
}
|
||||
@@ -1119,7 +1173,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
|
||||
m.updateContact(contact)
|
||||
var err: String
|
||||
switch chatError {
|
||||
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
|
||||
case .errorAgent(agentError: .BROKER(_, .NETWORK)): err = "network"
|
||||
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
|
||||
default: err = String(describing: chatError)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,15 +32,26 @@ struct ChatInfoToolbar: View {
|
||||
.frame(width: imageSize, height: imageSize)
|
||||
.padding(.trailing, 4)
|
||||
VStack {
|
||||
Text(cInfo.displayName).font(.headline)
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 220)
|
||||
}
|
||||
|
||||
private var contactVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.baselineOffset(1)
|
||||
.kerning(-2)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||
|
||||
@@ -18,6 +18,15 @@ func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
|
||||
}
|
||||
}
|
||||
|
||||
func infoRow(_ title: Text, _ value: String) -> some View {
|
||||
HStack {
|
||||
title
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
@@ -53,10 +62,11 @@ 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?
|
||||
@Binding var customUserProfile: Profile?
|
||||
@State var localAlias: String
|
||||
@Binding var connectionCode: String?
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@@ -89,7 +99,9 @@ struct ChatInfoView: View {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
localAliasTextEdit()
|
||||
Group {
|
||||
localAliasTextEdit()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
@@ -99,15 +111,18 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
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)
|
||||
@@ -141,17 +156,23 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func contactInfoHeader() -> some View {
|
||||
private func contactInfoHeader() -> some View {
|
||||
VStack {
|
||||
let cInfo = chat.chatInfo
|
||||
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
HStack {
|
||||
if contact.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
@@ -161,7 +182,7 @@ struct ChatInfoView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
func localAliasTextEdit() -> some View {
|
||||
private func localAliasTextEdit() -> some View {
|
||||
TextField("Set contact name…", text: $localAlias)
|
||||
.disableAutocorrection(true)
|
||||
.focused($aliasTextFieldFocused)
|
||||
@@ -192,7 +213,50 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func networkStatusRow() -> some View {
|
||||
private func verifyCodeButton(_ code: String) -> some View {
|
||||
NavigationLink {
|
||||
VerifyCodeView(
|
||||
displayName: contact.displayName,
|
||||
connectionCode: code,
|
||||
connectionVerified: contact.verified,
|
||||
verify: { code in
|
||||
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
|
||||
let (verified, existingCode) = r
|
||||
contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
connectionCode = existingCode
|
||||
DispatchQueue.main.async {
|
||||
chat.chatInfo = .direct(contact: contact)
|
||||
}
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Security code")
|
||||
} label: {
|
||||
Label(
|
||||
contact.verified ? "View security code" : "Verify security code",
|
||||
systemImage: contact.verified ? "checkmark.shield" : "shield"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private 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")
|
||||
}
|
||||
}
|
||||
|
||||
private func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
Image(systemName: "info.circle")
|
||||
@@ -205,14 +269,14 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func serverImage() -> some View {
|
||||
private func serverImage() -> some View {
|
||||
let status = chat.serverInfo.networkStatus
|
||||
return Image(systemName: status.imageName)
|
||||
.foregroundColor(status == .connected ? .green : .secondary)
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
|
||||
func deleteContactButton() -> some View {
|
||||
private func deleteContactButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteContactAlert
|
||||
} label: {
|
||||
@@ -221,7 +285,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func clearChatButton() -> some View {
|
||||
private func clearChatButton() -> some View {
|
||||
Button() {
|
||||
alert = .clearChatAlert
|
||||
} label: {
|
||||
@@ -307,7 +371,9 @@ struct ChatInfoView_Previews: PreviewProvider {
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
||||
contact: Contact.sampleData,
|
||||
connectionStats: Binding.constant(nil),
|
||||
localAlias: ""
|
||||
customUserProfile: Binding.constant(nil),
|
||||
localAlias: "",
|
||||
connectionCode: Binding.constant(nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
63
apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift
Normal file
63
apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// Created by Avently on 19.12.2022.
|
||||
// Copyright (c) 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class AnimatedImageView: UIView {
|
||||
var image: UIImage? = nil
|
||||
var imageView: UIImageView? = nil
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
||||
convenience init(image: UIImage) {
|
||||
self.init()
|
||||
self.image = image
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
self.addSubview(imageView!)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
imageView!.frame = bounds
|
||||
}
|
||||
|
||||
func updateImage(_ image: UIImage) {
|
||||
if let subview = self.subviews.first as? UIImageView {
|
||||
if image.imageData != subview.gifImage?.imageData {
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
self.addSubview(imageView!)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
imageView!.frame = bounds
|
||||
self.layoutSubviews()
|
||||
}
|
||||
}
|
||||
|
||||
struct SwiftyGif: UIViewRepresentable {
|
||||
private let image: UIImage
|
||||
|
||||
init(image: UIImage) {
|
||||
self.image = image
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> AnimatedImageView {
|
||||
AnimatedImageView(image: image)
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||
imageView.updateImage(image)
|
||||
imageView.imageView!.startAnimatingGif()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
Normal file
36
apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// 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 icon: String? = nil
|
||||
var iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Image(systemName: icon ?? feature.iconFilled)
|
||||
.foregroundColor(iconColor)
|
||||
.scaleEffect(feature.iconScale)
|
||||
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: ChatFeature.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 {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// CIFeaturePreferenceView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 21/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIFeaturePreferenceView: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var feature: ChatFeature
|
||||
var allowed: FeatureAllowed
|
||||
var param: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: feature.icon)
|
||||
.foregroundColor(.secondary)
|
||||
.scaleEffect(feature.iconScale)
|
||||
if let ct = chat.chatInfo.contact,
|
||||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
|
||||
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
|
||||
.onTapGesture {
|
||||
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
|
||||
}
|
||||
} else {
|
||||
featurePreferenceView()
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
||||
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
if let acceptText {
|
||||
r = r
|
||||
+ Text(acceptText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.accentColor)
|
||||
+ Text(" ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
return r.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
func allowFeatureToContact(_ contact: Contact, _ feature: ChatFeature, param: Int? = nil) {
|
||||
Task {
|
||||
do {
|
||||
let prefs = contactUserPreferencesToPreferences(contact.mergedPreferences).setAllowed(feature, param: param)
|
||||
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContact(toContact)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("allowFeatureToContact apiSetContactPrefs error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFeaturePreferenceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let content = CIContent.rcvChatPreference(feature: .timedMessages, allowed: .yes, param: 30)
|
||||
let chatItem = ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false),
|
||||
content: content,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
@@ -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,18 +148,19 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
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))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile)
|
||||
Group {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user