Compare commits

...

55 Commits

Author SHA1 Message Date
spaced4ndy
6cf94262cf fix names 2023-08-18 22:04:42 +04:00
spaced4ndy
f55d361bc4 fix, tests 2023-08-18 22:02:24 +04:00
spaced4ndy
ff4c8659d0 core: allow repeat join via group link 2023-08-18 21:32:07 +04:00
Evgeny Poberezkin
41c68c82ac directory: add to website, send terms, sort search results (#2950)
* directory: add to website, send terms, sort search results

* corrections
2023-08-18 14:31:42 +01:00
spaced4ndy
addace9faf ios: fix group receipts override (#2951) 2023-08-18 17:02:11 +04:00
Evgeny Poberezkin
eb223f0c53 Merge branch 'stable' 2023-08-18 11:04:25 +01:00
Evgeny Poberezkin
4a99f58b93 docs: update privacy policy, directory service doc/terms (#2910)
* docs: update privacy policy, directory service doc/terms

* update

* correction

* corrections

* update doc

* heading

* amended

* correction
2023-08-18 11:03:55 +01:00
spaced4ndy
bb39a04d4f 5.3.0-beta.5: ios 168, android 147 2023-08-18 11:27:17 +04:00
Evgeny Poberezkin
8bb19db1ff core: 5.3.0.5 2023-08-17 23:16:23 +01:00
spaced4ndy
b829bd0c06 ios: fix Speak Screen repeating messages (#2897)
* ios: fix Speak Screen repeating messages

* fix

* different modifier

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-17 23:05:19 +04:00
Evgeny Poberezkin
01a95f88bb ios: more responsive UI, especially during app start (#2942)
* ios: more responsive UI, especially during app start

* move terminal items to actor

* fix for iOS 15/16
2023-08-17 18:21:05 +01:00
M. Sarmad Qadeer
45b7d09f83 website: lowercase the website urls (#2893) 2023-08-17 16:27:55 +01:00
Stanislav Dmitrenko
f1c86d20c9 desktop: cursor on hover over clickable link (#2946)
* desktop: cursor on hover over clickable link

* simplex link

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-17 16:25:52 +01:00
Stanislav Dmitrenko
ea397049f8 multiplatform: ChatView enhancements (#2945)
* multiplatform: ChatView enhancements

* removed unused code

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-17 15:26:11 +01:00
Stanislav Dmitrenko
63ca7a34ff desktop: clickable links in quoted messages (#2943) 2023-08-17 15:20:27 +01:00
Stanislav Dmitrenko
107b6e1aec multiplatform: mark read fix (#2932)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-17 15:06:42 +01:00
spaced4ndy
1d8a370c58 android: show chat previews & save last draft toggles (#2941)
* android: show chat previews & save last draft toggles

* wip

* Revert "wip"

This reverts commit 01b570913f.

* comment

* add to model

* toggle text and icon

* show draft

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-17 15:04:16 +01:00
spaced4ndy
faea5e90ac android: members connected aggregated item; group layout (#2934) 2023-08-17 15:56:43 +04:00
Evgeny Poberezkin
590644a359 ios: show draft even when messages in the list are hidden (#2944) 2023-08-17 12:52:03 +01:00
spaced4ndy
a5940962c7 ios: show chat previews & save last draft toggles (#2940)
* ios: show chat previews & save last draft toggles

* better view, toggle name

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-17 12:04:17 +01:00
Stanislav Dmitrenko
6cf9f0303b desktop: handling keyboard in auth screen (#2938)
* desktop: handling keyboard in auth screen

* numpad support

* numPadEnter

* padding
2023-08-16 18:50:27 +01:00
spaced4ndy
34cf672bc6 core: show count and average time for slow queries (#2939) 2023-08-16 21:21:12 +04:00
spaced4ndy
4a5dd0a3a4 core: add indexes to improve slow queries performance (#2931)
* core: add indexes to improve slow queries performance

* idx_chat_items_group_id

* update simplexmq, schema

* update simplexmq

* remove index

* update simplexmq

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-16 10:41:52 +04:00
Evgeny Poberezkin
a5642928eb ios: improve group layout (#2925)
* ios: improve group layout

* different font

* fix formatting

* returns

* localized strings

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2023-08-15 13:02:23 +01:00
Stanislav Dmitrenko
21dcb3b856 multiplatform: better draft (#2926)
* multiplatform: better draft

* added context item to check for emptyness

* show draft in preview when chat is active

* state sync

* clear draft when sending message
2023-08-15 12:04:24 +01:00
Evgeny Poberezkin
5a9ed86d1b ios: send button color for incognito conversations (#2918) 2023-08-14 22:55:18 +01:00
Stanislav Dmitrenko
67acc89dbf desktop: close info panel on chat deletion (#2927)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-14 22:45:29 +01:00
Stanislav Dmitrenko
5bcb62b306 desktop: alert style in protocol servers (#2928) 2023-08-14 22:26:36 +01:00
Stanislav Dmitrenko
4ccc4c1b82 multiplatform: do not remove focus from search field on member selection (#2923) 2023-08-14 21:31:41 +01:00
Stanislav Dmitrenko
58e6a408ea multiplatform: handling wrongly pasted link (#2922) 2023-08-14 21:21:19 +01:00
Stanislav Dmitrenko
45f58e34d5 multiplatform: do not close non-active chat on delete/leave (#2921)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-14 21:19:16 +01:00
Stanislav Dmitrenko
782355ccb5 desktop: better logic when switching chats (#2898)
* desktop: better logic when switching chats

* auto scroll to top when selected chat changes

* multiplatform: members page performance

* preloading group members

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-14 21:05:53 +01:00
spaced4ndy
8dcb70c019 ios: members connected aggregated item (#2900)
* ios: members connected aggregated item

* wrapping hstack wip

* Revert "wrapping hstack wip"

This reverts commit 75af7473fc.

* redesign

* fix scroll

* revert

* comment

* remove padding

* brackets

* texts, icon

* optimize - collect only member names

* refactor

* check different index

* refactor 2

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-14 17:34:22 +04:00
Evgeny Poberezkin
e326227d06 cli: change default member role from "admin" to "member" (#2915) 2023-08-14 07:37:04 +01:00
Evgeny Poberezkin
1cc14346b0 ios: optmize chat console performance (#2912) 2023-08-14 07:36:39 +01:00
Evgeny Poberezkin
4f9683f678 5.3.0-beta.4: ios 167, android 146 2023-08-12 23:28:36 +01:00
Evgeny Poberezkin
48261b7e8f core: 5.3.0.4 2023-08-12 22:00:25 +01:00
Evgeny Poberezkin
d0f4533a09 Merge branch 'stable' 2023-08-12 21:21:45 +01:00
Evgeny Poberezkin
8f9134b494 5.2.3: ios 166, android 144 2023-08-12 21:00:33 +01:00
Evgeny Poberezkin
113669ac16 core: track slow SQL queries (#2904)
* core: track slow SQL queries

* fixes

* update simplexmq
2023-08-12 18:27:10 +01:00
Moritz Angermann
85ddb646af core: explicitly set encoding to utf-8 (fixes unicode filenames on mobile) (#2908)
* Explicitly set encoding to utf-8 on mobile

GHC's rts tries to obtain the encoding from iconv, however this is not
really available on iOS. It therefore defaults to US-ASCII, and then
breaks with unicode data. We now explicitly set this to utf-8 here.

* ormolu

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-12 18:15:11 +01:00
Evgeny Poberezkin
cee0dffd46 core: 5.2.3.0 2023-08-12 14:33:17 +01:00
Evgeny Poberezkin
a2fef15440 android: disable app data backup competely (#2907) 2023-08-12 14:17:00 +01:00
Evgeny Poberezkin
0176bc3b2c core: improve members query performance (#2903) 2023-08-12 13:53:08 +01:00
Evgeny Poberezkin
42324b515d ios: only show "entity" notifications if the are enabled in the conversation (#2899) 2023-08-11 16:55:00 +01:00
Stanislav Dmitrenko
1bc880877d desktop: changing language without reload (#2896)
* desktop: changing language without reload

* comment

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-11 11:00:48 +01:00
Evgeny Poberezkin
7a41957d7b android: add Bulgarian UI language 2023-08-11 10:40:04 +01:00
Evgeny Poberezkin
837b6dcf46 directory: do not send welcome message to members joining the group (#2895) 2023-08-11 10:38:56 +01:00
sh
77a20f1ae3 docs: update and include tor installation section (#2806) 2023-08-10 22:12:04 +01:00
Stanislav Dmitrenko
e2dfc2071f desktop: fix scrolling in some cases (#2888) 2023-08-10 22:10:11 +01:00
Stanislav Dmitrenko
b8f289039a multiplatform: better performance in two cases (#2879)
* multiplatform: better performance in two cases

* remove unused API

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-10 22:00:18 +01:00
Stanislav Dmitrenko
b02eb79a2c multiplatform: showing progress indicator while sending a text message (#2880)
* multiplatform: showing progress indicator while sending a text message

* progress indicator after timeout

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-10 21:49:44 +01:00
Stanislav Dmitrenko
6ce00b45aa ios: show progress after send with timeout correctly (#2887)
* ios: show progress after send with timeout correctly

* was wrong idea

* remove unused property

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-10 21:38:11 +01:00
Evgeny Poberezkin
97a1a00f17 website: translations (#2891)
* Translated using Weblate (Arabic)

Currently translated at 100.0% (234 of 234 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (234 of 234 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (234 of 234 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/

* Translated using Weblate (German)

Currently translated at 99.5% (233 of 234 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/

---------

Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org>
Co-authored-by: Pixelcode <pixelcode@dismail.de>
2023-08-10 21:15:31 +01:00
Stanislav Dmitrenko
121bca83aa multiplatform: format translations (#2892)
* multiplatform: format translations

* update strings

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-08-10 21:14:32 +01:00
113 changed files with 2296 additions and 1104 deletions

View File

@@ -6,27 +6,63 @@ If you believe that some of the clauses in this document are not aligned with ou
## Privacy Policy
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack).
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).
SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections 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 assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
### Information you provide
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
#### User profiles
Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
We do not store user profiles. The profile you create in the app is local to your device.
Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on the servers that you configured in the app, in case it allows such configuration (SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default). At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot see it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
#### Messages and Files
SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users.
The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers).
If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week).
#### Connections with other users
When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default.
At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### iOS Push Notifications
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers.
It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
#### Another information stored on the servers
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
#### SimpleX Directory Service
[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
#### User Support.
If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
### Information we may share
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service.
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
@@ -39,19 +75,19 @@ At the time of updating this document, we have never provided or have been reque
### Updates
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
Please also read our Terms of Service.
Please also read our Terms of Service below.
If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Terms of Service
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
@@ -67,15 +103,17 @@ You accept to our Terms of Service ("Terms") by installing or using any of our a
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration.
**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files.
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app.
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
@@ -93,4 +131,4 @@ You accept to our Terms of Service ("Terms") by installing or using any of our a
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
Updated November 8, 2022
Updated August 17, 2022

View File

@@ -54,7 +54,7 @@ You also can:
- criticize the app, and make comparisons with other messengers.
- share new messengers you think could be interesting for privacy, as long as you don't spam.
- share some privacy related publications, infrequently.
- having preliminary approved with the admin in direct message, share the link to a group you created.
- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it.
You must:
- be polite to other users
@@ -79,6 +79,8 @@ There are groups in other languages, that we have the apps interface translated
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
You can also join the group created by other users by searching for them via the [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups.
## Make a private connection
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.

View File

@@ -11,6 +11,38 @@ import Combine
import SwiftUI
import SimpleXChat
actor TerminalItems {
private var terminalItems: [TerminalItem] = []
static let shared = TerminalItems()
func items() -> [TerminalItem] {
terminalItems
}
func add(_ item: TerminalItem) async {
addTermItem(&terminalItems, item)
let m = ChatModel.shared
if m.showingTerminal {
await MainActor.run {
addTermItem(&m.terminalItems, item)
}
}
}
func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async {
addTermItem(&terminalItems, .cmd(start, cmd))
addTermItem(&terminalItems, .resp(.now, resp))
}
}
private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
if items.count >= 200 {
items.removeFirst()
}
items.append(item)
}
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -33,6 +65,7 @@ final class ChatModel: ObservableObject {
@Published var chatToTop: String?
@Published var groupMembers: [GroupMember] = []
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: UserContactLink?
@Published var chatItemTTL: ChatItemTTL = .none
@@ -483,14 +516,27 @@ final class ChatModel: ObservableObject {
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
}
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
return reversedChatItems[i + 1]
func getConnectedMemberNames(_ ci: ChatItem) -> [String] {
guard var i = getChatItemIndex(ci) else { return [] }
var ns: [String] = []
while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected {
ns.append(m.displayName)
i += 1
}
return ns
}
func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) {
if let i = getChatItemIndex(ci) {
return (
i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil,
i - 1 >= 0 ? reversedChatItems[i - 1] : nil
)
} else {
return nil
return (nil, nil)
}
}
func popChat(_ id: String) {
if let i = getChatIndex(id) {
popChat_(i)
@@ -579,13 +625,6 @@ final class ChatModel: ObservableObject {
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
networkStatuses[contact.activeConn.agentConnId] ?? .unknown
}
func addTerminalItem(_ item: TerminalItem) {
if terminalItems.count >= 500 {
terminalItems.remove(at: 0)
}
terminalItems.append(item)
}
}
struct NTFContactRequest {

View File

@@ -94,9 +94,8 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
DispatchQueue.main.async {
ChatModel.shared.addTerminalItem(.cmd(start, cmd.obfuscated))
ChatModel.shared.addTerminalItem(.resp(.now, resp))
Task {
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
}
return resp
}
@@ -321,12 +320,18 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
let r: ChatResponse
if type == .direct {
var cItem: ChatItem!
let endTask = beginBGTask({ if cItem != nil { chatModel.messageDelivery.removeValue(forKey: cItem.id) } })
var cItem: ChatItem? = nil
let endTask = beginBGTask({
if let cItem = cItem {
DispatchQueue.main.async {
chatModel.messageDelivery.removeValue(forKey: cItem.id)
}
}
})
r = await chatSendCmd(cmd, bgTask: false)
if case let .newChatItem(_, aChatItem) = r {
cItem = aChatItem.chatItem
chatModel.messageDelivery[cItem.id] = endTask
chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
return cItem
}
if let networkErrorAlert = networkErrorAlert(r) {
@@ -804,7 +809,7 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
func receiveFile(user: User, fileId: Int64, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(fileId: fileId, auto: auto) {
DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
await chatItemSimpleUpdate(user, chatItem)
}
}
@@ -842,7 +847,7 @@ func apiReceiveFile(fileId: Int64, inline: Bool? = nil, auto: Bool = false) asyn
func cancelFile(user: User, fileId: Int64) async {
if let chatItem = await apiCancelFile(fileId: fileId) {
DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
await chatItemSimpleUpdate(user, chatItem)
cleanupFile(chatItem)
}
}
@@ -1244,38 +1249,50 @@ class ChatReceiver {
}
func processReceivedMsg(_ res: ChatResponse) async {
Task {
await TerminalItems.shared.add(.resp(.now, res))
}
let m = ChatModel.shared
await MainActor.run {
m.addTerminalItem(.resp(.now, res))
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
case let .newContactConnection(user, connection):
if active(user) {
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
case let .newContactConnection(user, connection):
if active(user) {
await MainActor.run {
m.updateContactConnection(connection)
}
case let .contactConnectionDeleted(user, connection):
if active(user) {
}
case let .contactConnectionDeleted(user, connection):
if active(user) {
await MainActor.run {
m.removeChat(connection.id)
}
case let .contactConnected(user, contact, _):
if active(user) && contact.directOrUsed {
}
case let .contactConnected(user, contact, _):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
}
if contact.directOrUsed {
NtfManager.shared.notifyContactConnected(user, contact)
}
}
if contact.directOrUsed {
NtfManager.shared.notifyContactConnected(user, contact)
}
await MainActor.run {
m.setContactNetworkStatus(contact, .connected)
case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed {
}
case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
}
case let .receivedContactRequest(user, contactRequest):
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
}
case let .receivedContactRequest(user, contactRequest):
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
await MainActor.run {
if m.hasChat(contactRequest.id) {
m.updateChatInfo(cInfo)
} else {
@@ -1285,234 +1302,285 @@ func processReceivedMsg(_ res: ChatResponse) async {
))
}
}
NtfManager.shared.notifyContactRequest(user, contactRequest)
case let .contactUpdated(user, toContact):
if active(user) && m.hasChat(toContact.id) {
}
NtfManager.shared.notifyContactRequest(user, contactRequest)
case let .contactUpdated(user, toContact):
if active(user) && m.hasChat(toContact.id) {
await MainActor.run {
let cInfo = ChatInfo.direct(contact: toContact)
m.updateChatInfo(cInfo)
}
case let .contactsMerged(user, intoContact, mergedContact):
if active(user) && m.hasChat(mergedContact.id) {
}
case let .contactsMerged(user, intoContact, mergedContact):
if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run {
if m.chatId == mergedContact.id {
m.chatId = intoContact.id
}
m.removeChat(mergedContact.id)
}
case let .contactsSubscribed(_, contactRefs):
updateContactsStatus(contactRefs, status: .connected)
case let .contactsDisconnected(_, contactRefs):
updateContactsStatus(contactRefs, status: .disconnected)
case let .contactSubError(user, contact, chatError):
}
case let .contactsSubscribed(_, contactRefs):
await updateContactsStatus(contactRefs, status: .connected)
case let .contactsDisconnected(_, contactRefs):
await updateContactsStatus(contactRefs, status: .disconnected)
case let .contactSubError(user, contact, chatError):
await MainActor.run {
if active(user) {
m.updateContact(contact)
}
processContactSubError(contact, chatError)
case let .contactSubSummary(user, contactSubscriptions):
}
case let .contactSubSummary(_, contactSubscriptions):
await MainActor.run {
for sub in contactSubscriptions {
if active(user) {
m.updateContact(sub.contact)
}
// no need to update contact here, and it is slow
// if active(user) {
// m.updateContact(sub.contact)
// }
if let err = sub.contactError {
processContactSubError(sub.contact, err)
} else {
m.setContactNetworkStatus(sub.contact, .connected)
}
}
case let .newChatItem(user, aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
}
case let .newChatItem(user, aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
await MainActor.run {
if active(user) {
m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
if let file = cItem.autoReceiveFile() {
Task {
await receiveFile(user: user, fileId: file.fileId, auto: true)
}
}
if let file = cItem.autoReceiveFile() {
Task {
await receiveFile(user: user, fileId: file.fileId, auto: true)
}
if cItem.showNotification {
}
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
case let .chatItemStatusUpdated(user, aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if !cItem.isDeletedContent {
let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true
if added && cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
case let .chatItemStatusUpdated(user, aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if !cItem.isDeletedContent {
let added = active(user) ? m.upsertChatItem(cInfo, cItem) : true
if added && cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
case .sndSent: endTask()
case .sndErrorAuth: endTask()
case .sndError: endTask()
default: ()
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
case .sndSent: endTask()
case .sndErrorAuth: endTask()
case .sndError: endTask()
default: ()
}
}
case let .chatItemUpdated(user, aChatItem):
chatItemSimpleUpdate(user, aChatItem)
case let .chatItemReaction(user, _, r):
if active(user) {
}
case let .chatItemUpdated(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
case let .chatItemReaction(user, _, r):
if active(user) {
await MainActor.run {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
if !active(user) {
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
}
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
if !active(user) {
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
return
}
return
}
await MainActor.run {
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
}
case let .receivedGroupInvitation(user, groupInfo, _, _):
if active(user) {
}
case let .receivedGroupInvitation(user, groupInfo, _, _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
}
case let .userAcceptedGroupSent(user, groupInfo, hostContact):
if !active(user) { return }
}
case let .userAcceptedGroupSent(user, groupInfo, hostContact):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
if let hostContact = hostContact {
m.dismissConnReqView(hostContact.activeConn.id)
m.removeChat(hostContact.activeConn.id)
}
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
if active(user) {
}
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
if active(user) {
}
case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
}
case let .deletedMember(user, groupInfo, _, deletedMember):
if active(user) {
}
case let .deletedMember(user, groupInfo, _, deletedMember):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, deletedMember)
}
case let .leftMember(user, groupInfo, member):
if active(user) {
}
case let .leftMember(user, groupInfo, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
case let .groupDeleted(user, groupInfo, _): // TODO update user member
if active(user) {
}
case let .groupDeleted(user, groupInfo, _): // TODO update user member
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
}
case let .userJoinedGroup(user, groupInfo):
if active(user) {
}
case let .userJoinedGroup(user, groupInfo):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
}
case let .joinedGroupMember(user, groupInfo, member):
if active(user) {
}
case let .joinedGroupMember(user, groupInfo, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
case let .connectedToGroupMember(user, groupInfo, member, memberContact):
if active(user) {
}
case let .connectedToGroupMember(user, groupInfo, member, memberContact):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
if let contact = memberContact {
}
if let contact = memberContact {
await MainActor.run {
m.setContactNetworkStatus(contact, .connected)
}
case let .groupUpdated(user, toGroup):
if active(user) {
}
case let .groupUpdated(user, toGroup):
if active(user) {
await MainActor.run {
m.updateGroup(toGroup)
}
case let .memberRole(user, groupInfo, _, _, _, _):
if active(user) {
}
case let .memberRole(user, groupInfo, _, _, _, _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
}
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileStart(user, aChatItem):
chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem):
chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileSndCancelled(user, aChatItem, _):
chatItemSimpleUpdate(user, aChatItem)
cleanupFile(aChatItem)
case let .rcvFileProgressXFTP(user, aChatItem, _, _):
chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileError(user, aChatItem):
chatItemSimpleUpdate(user, aChatItem)
cleanupFile(aChatItem)
case let .sndFileStart(user, aChatItem, _):
chatItemSimpleUpdate(user, aChatItem)
case let .sndFileComplete(user, aChatItem, _):
chatItemSimpleUpdate(user, aChatItem)
cleanupDirectFile(aChatItem)
case let .sndFileRcvCancelled(user, aChatItem, _):
chatItemSimpleUpdate(user, aChatItem)
cleanupDirectFile(aChatItem)
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
chatItemSimpleUpdate(user, aChatItem)
case let .sndFileCompleteXFTP(user, aChatItem, _):
chatItemSimpleUpdate(user, aChatItem)
cleanupFile(aChatItem)
case let .sndFileError(user, aChatItem):
chatItemSimpleUpdate(user, aChatItem)
cleanupFile(aChatItem)
case let .callInvitation(invitation):
m.callInvitations[invitation.contact.id] = invitation
activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _):
withCall(contact) { call in
call.callState = .offerReceived
call.peerMedia = callType.media
call.sharedKey = sharedKey
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
m.callCommand = .offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
)
}
case let .callAnswer(_, contact, answer):
withCall(contact) { call in
call.callState = .answerReceived
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
}
case let .callExtraInfo(_, contact, extraInfo):
withCall(contact) { _ in
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
}
case let .callEnded(_, contact):
if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
CallController.shared.reportCallRemoteEnded(invitation: invitation)
}
withCall(contact) { call in
m.callCommand = .end
CallController.shared.reportCallRemoteEnded(call: call)
}
case .chatSuspended:
chatSuspended()
case let .contactSwitch(_, contact, switchProgress):
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
case let .contactRatchetSync(_, contact, ratchetSyncProgress):
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
default:
logger.debug("unsupported event: \(res.responseType)")
}
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileStart(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileSndCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
case let .rcvFileProgressXFTP(user, aChatItem, _, _):
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileError(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
case let .sndFileStart(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileComplete(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupDirectFile(aChatItem) }
case let .sndFileRcvCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupDirectFile(aChatItem) }
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileCompleteXFTP(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
case let .sndFileError(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
case let .callInvitation(invitation):
m.callInvitations[invitation.contact.id] = invitation
activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _):
await withCall(contact) { call in
call.callState = .offerReceived
call.peerMedia = callType.media
call.sharedKey = sharedKey
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
m.callCommand = .offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
)
}
case let .callAnswer(_, contact, answer):
await withCall(contact) { call in
call.callState = .answerReceived
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
}
case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
}
case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
CallController.shared.reportCallRemoteEnded(invitation: invitation)
}
await withCall(contact) { call in
m.callCommand = .end
CallController.shared.reportCallRemoteEnded(call: call)
}
case .chatSuspended:
chatSuspended()
case let .contactSwitch(_, contact, switchProgress):
await MainActor.run {
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
}
case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
}
case let .contactRatchetSync(_, contact, ratchetSyncProgress):
await MainActor.run {
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
}
case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
}
default:
logger.debug("unsupported event: \(res.responseType)")
}
func withCall(_ contact: Contact, _ perform: (Call) -> Void) {
if let call = m.activeCall, call.contact.apiId == contact.apiId {
perform(call)
} else {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
}
func withCall(_ contact: Contact, _ perform: (Call) -> Void) async {
if let call = m.activeCall, call.contact.apiId == contact.apiId {
await MainActor.run { perform(call) }
} else {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
}
}
}
@@ -1521,19 +1589,23 @@ func active(_ user: User) -> Bool {
user.id == ChatModel.shared.currentUser?.id
}
func chatItemSimpleUpdate(_ user: User, _ aChatItem: AChatItem) {
func chatItemSimpleUpdate(_ user: User, _ aChatItem: AChatItem) async {
let m = ChatModel.shared
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if active(user) && m.upsertChatItem(cInfo, cItem) {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
if active(user) {
if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
}
}
func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) {
func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) async {
let m = ChatModel.shared
for c in contactRefs {
m.networkStatuses[c.agentConnId] = status
await MainActor.run {
for c in contactRefs {
m.networkStatuses[c.agentConnId] = status
}
}
}
@@ -1572,7 +1644,9 @@ func activateCall(_ callInvitation: RcvCallInvitation) {
let m = ChatModel.shared
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
DispatchQueue.main.async {
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
}
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
logger.debug("reportNewIncomingCall success")

View File

@@ -10,20 +10,11 @@ import SwiftUI
import SimpleXChat
struct CIEventView: View {
var chatItem: ChatItem
var eventText: Text
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
if let member = chatItem.memberDisplayName {
Text(member)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ chatEventText(chatItem)
} else {
chatEventText(chatItem)
}
eventText
}
.padding(.leading, 6)
.padding(.bottom, 6)
@@ -31,20 +22,8 @@ struct CIEventView: View {
}
}
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 {
static var previews: some View {
CIEventView(chatItem: ChatItem.getGroupEventSample())
CIEventView(eventText: Text("event happened"))
}
}

View File

@@ -16,7 +16,6 @@ struct CIRcvDecryptionError: View {
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
var showMember = false
@State private var alert: CIRcvDecryptionErrorAlert?
enum CIRcvDecryptionErrorAlert: Identifiable {
@@ -106,9 +105,6 @@ struct CIRcvDecryptionError: View {
ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 2) {
HStack {
if showMember, let member = chatItem.memberDisplayName {
Text(member).fontWeight(.medium) + Text(": ")
}
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
@@ -137,20 +133,13 @@ struct CIRcvDecryptionError: View {
}
private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View {
func text() -> Text {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
}
return ZStack(alignment: .bottomTrailing) {
HStack {
if showMember, let member = chatItem.memberDisplayName {
Text(member).fontWeight(.medium) + Text(": ") + text()
} else {
text()
}
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
}
.padding(.horizontal, 12)
CIMetaView(chatItem: chatItem)

View File

@@ -12,13 +12,9 @@ import SimpleXChat
struct DeletedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var showMember = false
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
if showMember, let member = chatItem.memberDisplayName {
Text(member).fontWeight(.medium) + Text(": ")
}
Text(chatItem.content.text)
.foregroundColor(.secondary)
.italic()
@@ -37,10 +33,7 @@ struct DeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
DeletedItemView(
chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)),
showMember: true
)
DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
}
.previewLayout(.fixed(width: 360, height: 200))
}

View File

@@ -18,7 +18,6 @@ struct FramedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatInfo: ChatInfo
var chatItem: ChatItem
var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@State var msgWidth: CGFloat = 0
@@ -57,7 +56,7 @@ struct FramedItemView: View {
}
}
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: framedMsgContentView)
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: framedMsgContentView)
.padding(chatItem.content.msgContent != nil ? 0 : 4)
.overlay(DetermineWidth())
}
@@ -68,6 +67,7 @@ struct FramedItemView: View {
.padding(.horizontal, 12)
.padding(.bottom, 6)
.overlay(DetermineWidth())
.accessibilityLabel("")
}
}
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
@@ -107,7 +107,7 @@ struct FramedItemView: View {
value: .white
)
} else {
ciMsgContentView (chatItem, showMember)
ciMsgContentView(chatItem)
}
case let .video(text, image, duration):
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
@@ -120,27 +120,27 @@ struct FramedItemView: View {
value: .white
)
} else {
ciMsgContentView (chatItem, showMember)
ciMsgContentView(chatItem)
}
case let .voice(text, duration):
FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.overlay(DetermineWidth())
if text != "" {
ciMsgContentView (chatItem, showMember)
ciMsgContentView(chatItem)
}
case let .file(text):
ciFileView(chatItem, text)
case let .link(_, preview):
CILinkView(linkPreview: preview)
ciMsgContentView (chatItem, showMember)
ciMsgContentView(chatItem)
case let .unknown(_, text: text):
if chatItem.file == nil {
ciMsgContentView (chatItem, showMember)
ciMsgContentView(chatItem)
} else {
ciFileView(chatItem, text)
}
default:
ciMsgContentView (chatItem, showMember)
ciMsgContentView(chatItem)
}
}
}
@@ -232,17 +232,27 @@ struct FramedItemView: View {
}
private func ciQuotedMsgView(_ qi: CIQuote) -> some View {
MsgContentView(
text: qi.text,
formattedText: qi.formattedText,
sender: qi.getSender(membership())
)
.lineLimit(3)
.font(.subheadline)
.padding(.vertical, 6)
Group {
if let sender = qi.getSender(membership()) {
VStack(alignment: .leading, spacing: 2) {
Text(sender).font(.caption).foregroundColor(.secondary)
ciQuotedMsgTextView(qi, lines: 2)
}
} else {
ciQuotedMsgTextView(qi, lines: 3)
}
}
.padding(.top, 6)
.padding(.horizontal, 12)
}
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
MsgContentView(text: qi.text, formattedText: qi.formattedText)
.lineLimit(lines)
.font(.subheadline)
.padding(.bottom, 6)
}
private func ciQuoteIconView(_ image: String) -> some View {
Image(systemName: image)
.resizable()
@@ -260,13 +270,12 @@ struct FramedItemView: View {
}
}
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View {
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text)
let v = MsgContentView(
text: text,
formattedText: text == "" ? [] : ci.formattedText,
sender: showMember ? ci.memberDisplayName : nil,
meta: ci.meta,
rightToLeft: rtl
)
@@ -288,7 +297,7 @@ struct FramedItemView: View {
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
.overlay(DetermineWidth())
if text != "" || ci.meta.isLive {
ciMsgContentView (chatItem, showMember)
ciMsgContentView (chatItem)
}
}

View File

@@ -12,10 +12,9 @@ import SimpleXChat
struct IntegrityErrorItemView: View {
var msgError: MsgErrorType
var chatItem: ChatItem
var showMember = false
var body: some View {
CIMsgError(chatItem: chatItem, showMember: showMember) {
CIMsgError(chatItem: chatItem) {
switch msgError {
case .msgSkipped:
AlertManager.shared.showAlertMsg(
@@ -54,14 +53,10 @@ struct IntegrityErrorItemView: View {
struct CIMsgError: View {
var chatItem: ChatItem
var showMember = false
var onTap: () -> Void
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
if showMember, let member = chatItem.memberDisplayName {
Text(member).fontWeight(.medium) + Text(": ")
}
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()

View File

@@ -12,13 +12,9 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var showMember = false
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
if showMember, let member = chatItem.memberDisplayName {
Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
}
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted {
markedDeletedText("moderated by \(byGroupMember.chatViewName)")
} else {

View File

@@ -12,7 +12,6 @@ import SimpleXChat
struct ChatItemView: View {
var chatInfo: ChatInfo
var chatItem: ChatItem
var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
@@ -23,7 +22,6 @@ struct ChatItemView: View {
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
self.chatInfo = chatInfo
self.chatItem = chatItem
self.showMember = showMember
self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed
@@ -36,14 +34,14 @@ struct ChatItemView: View {
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && !revealed {
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
MarkedDeletedItemView(chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -53,14 +51,14 @@ struct ChatItemView: View {
}
private func framedItemView() -> some View {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
}
}
struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel
var chatInfo: ChatInfo
var chatItem: ChatItem
var showMember: Bool
var msgContentView: () -> Content
var body: some View {
@@ -71,10 +69,11 @@ struct ChatItemContentView<Content: View>: View {
case .rcvDeleted: deletedItemView()
case let .sndCall(status, duration): callItemView(status, duration)
case let .rcvCall(status, duration): callItemView(status, duration)
case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem, showMember: showMember)
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem, showMember: showMember)
case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem)
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
case .rcvGroupEvent: eventItemView()
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
@@ -96,7 +95,7 @@ struct ChatItemContentView<Content: View>: View {
}
private func deletedItemView() -> some View {
DeletedItemView(chatItem: chatItem, showMember: showMember)
DeletedItemView(chatItem: chatItem)
}
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
@@ -108,12 +107,54 @@ struct ChatItemContentView<Content: View>: View {
}
private func eventItemView() -> some View {
CIEventView(chatItem: chatItem)
return CIEventView(eventText: eventItemViewText())
}
private func eventItemViewText() -> Text {
if let member = chatItem.memberDisplayName {
return Text(member + " ")
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
+ chatEventText(chatItem)
} else {
return chatEventText(chatItem)
}
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var membersConnectedItemText: Text {
if let t = membersConnectedText {
return chatEventText(t, chatItem.timestampText)
} else {
return eventItemViewText()
}
}
private var membersConnectedText: LocalizedStringKey? {
let ns = chatModel.getConnectedMemberNames(chatItem)
return ns.count > 3
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
: ns.count == 3
? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
: ns.count == 2
? "\(ns[0]) and \(ns[1]) connected"
: nil
}
}
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
(Text(eventText) + Text(" ") + ts)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
}
func chatEventText(_ ci: ChatItem) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText)
}
struct ChatItemView_Previews: PreviewProvider {

View File

@@ -261,7 +261,7 @@ struct ChatView: View {
return GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 5) {
LazyVStack(spacing: 0) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group
@@ -430,68 +430,77 @@ struct ChatView: View {
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let prevItem = chatModel.getPrevChatItem(ci)
HStack(alignment: .top, spacing: 0) {
let showMember = prevItem == nil || showMemberImage(member, prevItem)
if showMember {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture { selectedMember = member }
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci)
if ci.memberConnected != nil && nextItem?.memberConnected != nil {
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
ZStack {} // scroll doesn't work if it's EmptyView()
} else {
if prevItem == nil || showMemberImage(member, prevItem) {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
Text(member.displayName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, memberImageSize + 14)
.padding(.top, 7)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture { selectedMember = member }
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
}
chatItemWithMenu(ci, maxWidth)
}
}
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
Rectangle().fill(.clear)
.frame(width: memberImageSize, height: memberImageSize)
chatItemWithMenu(ci, maxWidth)
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
ChatItemWithMenu(
ci: ci,
showMember: showMember,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
deleteMessage: deleteMessage,
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
)
.padding(.leading, 8)
.environmentObject(chat)
}
.padding(.trailing)
.padding(.leading, 12)
} else {
ChatItemWithMenu(
ci: ci,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
deleteMessage: deleteMessage,
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
)
.padding(.horizontal)
.environmentObject(chat)
chatItemWithMenu(ci, maxWidth)
.padding(.horizontal)
.padding(.top, 5)
}
}
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
ChatItemWithMenu(
ci: ci,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
deleteMessage: deleteMessage,
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
)
.environmentObject(chat)
}
private struct ChatItemWithMenu: View {
@EnvironmentObject var chat: Chat
@Environment(\.colorScheme) var colorScheme
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat
var scrollProxy: ScrollViewProxy?
var deleteMessage: (CIDeleteMode) -> Void
@Binding var deletingItem: ChatItem?
@Binding var composeState: ComposeState
@Binding var showDeleteMessage: Bool
@State private var revealed = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
@State private var allowMenu: Bool = true
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
@@ -504,8 +513,9 @@ struct ChatView: View {
)
VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
chatItemReactions()
.padding(.bottom, 4)

View File

@@ -44,7 +44,6 @@ struct ComposeState {
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false
var disabled = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
init(
@@ -241,6 +240,7 @@ struct ComposeView: View {
@State var pendingLinkUrl: URL? = nil
@State var cancelledLinks: Set<String> = []
@Environment(\.colorScheme) private var colorScheme
@State private var showChooseSource = false
@State private var showMediaPicker = false
@State private var showTakePhoto = false
@@ -255,6 +255,8 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
var body: some View {
VStack(spacing: 0) {
contextItemView()
@@ -309,7 +311,10 @@ struct ComposeView: View {
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible
keyboardVisible: $keyboardVisible,
sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: .accentColor
)
.padding(.trailing, 12)
.background(.background)
@@ -442,7 +447,15 @@ struct ComposeView: View {
} else if (composeState.inProgress) {
clearCurrentDraft()
} else if !composeState.empty {
saveCurrentDraft()
if case .recording = composeState.voiceMessageRecordingState {
finishVoiceMessageRecording()
if let fileName = composeState.voiceMessageRecordingFileName {
chatModel.filesToDelete.insert(getAppFilePath(fileName))
}
}
if saveLastDraft {
saveCurrentDraft()
}
} else {
cancelCurrentVoiceRecording()
clearCurrentDraft()
@@ -655,10 +668,7 @@ struct ComposeView: View {
return sent
func sending() async {
await MainActor.run { composeState.disabled = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if composeState.disabled { composeState.inProgress = true }
}
await MainActor.run { composeState.inProgress = true }
}
func updateMessage(_ ei: ChatItem, live: Bool) async -> ChatItem? {
@@ -852,7 +862,6 @@ struct ComposeView: View {
private func clearState(live: Bool = false) {
if live {
composeState.disabled = false
composeState.inProgress = false
} else {
composeState = ComposeState()
@@ -865,12 +874,6 @@ struct ComposeView: View {
}
private func saveCurrentDraft() {
if case .recording = composeState.voiceMessageRecordingState {
finishVoiceMessageRecording()
if let fileName = composeState.voiceMessageRecordingFileName {
chatModel.filesToDelete.insert(getAppFilePath(fileName))
}
}
chatModel.draft = composeState
chatModel.draftChatId = chat.id
}

View File

@@ -22,13 +22,14 @@ struct ContextItemView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(.secondary)
MsgContentView(
text: contextItem.text,
formattedText: contextItem.formattedText,
sender: contextItem.memberDisplayName
)
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(3)
if let sender = contextItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) {
Text(sender).font(.caption).foregroundColor(.secondary)
msgContentView(lines: 2)
}
} else {
msgContentView(lines: 3)
}
Spacer()
Button {
withAnimation {
@@ -44,6 +45,15 @@ struct ContextItemView: View {
.background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8)
}
private func msgContentView(lines: Int) -> some View {
MsgContentView(
text: contextItem.text,
formattedText: contextItem.formattedText
)
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines)
}
}
struct ContextItemView_Previews: PreviewProvider {

View File

@@ -28,6 +28,7 @@ struct SendMessageView: View {
@State private var holdingVMR = false
@Namespace var namespace
@Binding var keyboardVisible: Bool
var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
@@ -36,6 +37,7 @@ struct SendMessageView: View {
@State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false
var maxHeight: CGFloat = 360
var minHeight: CGFloat = 37
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
@@ -81,7 +83,7 @@ struct SendMessageView: View {
}
}
if composeState.inProgress {
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
@@ -102,6 +104,15 @@ struct SendMessageView: View {
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
.frame(height: teHeight)
}
.onChange(of: composeState.inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
progressByTimeout = composeState.inProgress
}
} else {
progressByTimeout = false
}
}
.padding(.vertical, 8)
}
@@ -119,7 +130,7 @@ struct SendMessageView: View {
startVoiceMessageRecording: startVoiceMessageRecording,
finishVoiceMessageRecording: finishVoiceMessageRecording,
holdingVMR: $holdingVMR,
disabled: composeState.disabled
disabled: composeState.inProgress
)
} else {
voiceMessageNotAllowedButton()
@@ -159,13 +170,13 @@ struct SendMessageView: View {
? "checkmark.circle.fill"
: "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
.foregroundColor(sendButtonColor)
.frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity)
}
.disabled(
!composeState.sendEnabled ||
composeState.disabled ||
composeState.inProgress ||
(!voiceMessageAllowed && composeState.voicePreview) ||
composeState.endLiveDisabled
)
@@ -293,7 +304,7 @@ struct SendMessageView: View {
Image(systemName: "mic")
.foregroundColor(.secondary)
}
.disabled(composeState.disabled)
.disabled(composeState.inProgress)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
@@ -378,7 +389,7 @@ struct SendMessageView: View {
Image(systemName: "stop.fill")
.foregroundColor(.accentColor)
}
.disabled(composeState.disabled)
.disabled(composeState.inProgress)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}

View File

@@ -15,6 +15,8 @@ struct ChatPreviewView: View {
@Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
var body: some View {
let cItem = chat.chatItems.last
return HStack(spacing: 8) {
@@ -101,7 +103,7 @@ struct ChatPreviewView: View {
.kerning(-2)
}
private func chatPreviewLayout(_ text: Text) -> some View {
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) {
text
.lineLimit(2)
@@ -109,6 +111,8 @@ struct ChatPreviewView: View {
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
.privacySensitive(!showChatPreviews && !draft)
.redacted(reason: .privacy)
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
@@ -170,7 +174,7 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
chatPreviewLayout(messageDraft(draft))
chatPreviewLayout(messageDraft(draft), draft: true)
} else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
} else {

View File

@@ -22,10 +22,28 @@ struct TerminalView: View {
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
@State private var terminalItem: TerminalItem?
@State private var scrolled = false
@State private var showing = false
var body: some View {
if authorized {
terminalView()
.onAppear {
if showing { return }
showing = true
Task {
let items = await TerminalItems.shared.items()
await MainActor.run {
chatModel.terminalItems = items
chatModel.showingTerminal = true
}
}
}
.onDisappear {
if terminalItem == nil {
chatModel.showingTerminal = false
chatModel.terminalItems = []
}
}
} else {
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
.onAppear(perform: runAuth)
@@ -118,9 +136,8 @@ struct TerminalView: View {
let cmd = ChatCommand.string(composeState.message)
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
DispatchQueue.main.async {
ChatModel.shared.addTerminalItem(.cmd(.now, cmd))
ChatModel.shared.addTerminalItem(.resp(.now, resp))
Task {
await TerminalItems.shared.addCommand(.now, cmd, resp)
}
} else {
DispatchQueue.global().async {

View File

@@ -13,6 +13,8 @@ struct PrivacySettings: View {
@EnvironmentObject var m: ChatModel
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -70,6 +72,18 @@ struct PrivacySettings: View {
settingsRow("network") {
Toggle("Send link previews", isOn: $useLinkPreviews)
}
settingsRow("message") {
Toggle("Show last messages", isOn: $showChatPreviews)
}
settingsRow("rectangle.and.pencil.and.ellipsis") {
Toggle("Message draft", isOn: $saveLastDraft)
}
.onChange(of: saveLastDraft) { saveDraft in
if !saveDraft {
m.draft = nil
m.draftChatId = nil
}
}
settingsRow("link") {
Picker("SimpleX links", selection: $simplexLinkMode) {
ForEach(SimpleXLinkMode.values) { mode in
@@ -121,7 +135,7 @@ struct PrivacySettings: View {
Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
setSendReceiptsGroups(groupReceipts, clearOverrides: false)
}
Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
setSendReceiptsGroups(groupReceipts, clearOverrides: true)
}
Button("Cancel", role: .cancel) {

View File

@@ -30,6 +30,8 @@ let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
@@ -65,6 +67,8 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_EXPERIMENTAL_CALLS: false,
@@ -295,6 +299,10 @@ struct SettingsView: View {
}
.navigationTitle("Your settings")
}
.onDisappear {
chatModel.showingTerminal = false
chatModel.terminalItems = []
}
}
private func chatDatabaseRow() -> some View {

View File

@@ -127,7 +127,7 @@ class NotificationService: UNNotificationServiceExtension {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)")
if let connEntity = ntfMsgInfo.connEntity {
setBestAttemptNtf(
ntfMsgInfo.user.showNotifications
ntfMsgInfo.ntfsEnabled
? .nse(notification: createConnectionEventNtf(ntfMsgInfo.user, connEntity))
: .empty
)
@@ -401,4 +401,8 @@ struct NtfMessages {
var connEntity: ConnectionEntity?
var msgTs: Date?
var ntfMessages: [NtfMsgInfo]
var ntfsEnabled: Bool {
user.showNotifications && (connEntity?.ntfsEnabled ?? false)
}
}

View File

@@ -24,11 +24,6 @@
5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; };
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */; };
5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */; };
5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038F2A7EAA41006ACFE8 /* libffi.a */; };
5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403902A7EAA41006ACFE8 /* libgmp.a */; };
5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403912A7EAA41006ACFE8 /* libgmpxx.a */; };
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */; };
@@ -166,6 +161,11 @@
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; };
644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */; };
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
6462EF7A2A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6462EF752A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA-ghc8.10.7.a */; };
6462EF7B2A8F4448003B2EAF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6462EF762A8F4448003B2EAF /* libgmp.a */; };
6462EF7C2A8F4448003B2EAF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6462EF772A8F4448003B2EAF /* libgmpxx.a */; };
6462EF7D2A8F4448003B2EAF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6462EF782A8F4448003B2EAF /* libffi.a */; };
6462EF7E2A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6462EF792A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA.a */; };
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; };
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
@@ -263,11 +263,6 @@
5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = "<group>"; };
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a"; sourceTree = "<group>"; };
5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a"; sourceTree = "<group>"; };
5C04038F2A7EAA41006ACFE8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C0403902A7EAA41006ACFE8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C0403912A7EAA41006ACFE8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = "<group>"; };
@@ -443,6 +438,11 @@
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkedDeletedItemView.swift; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
6462EF752A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA-ghc8.10.7.a"; sourceTree = "<group>"; };
6462EF762A8F4448003B2EAF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
6462EF772A8F4448003B2EAF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
6462EF782A8F4448003B2EAF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
6462EF792A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA.a"; sourceTree = "<group>"; };
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; };
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
@@ -501,13 +501,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */,
5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */,
5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */,
6462EF7D2A8F4448003B2EAF /* libffi.a in Frameworks */,
6462EF7C2A8F4448003B2EAF /* libgmpxx.a in Frameworks */,
6462EF7A2A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA-ghc8.10.7.a in Frameworks */,
6462EF7E2A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA.a in Frameworks */,
6462EF7B2A8F4448003B2EAF /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -568,11 +568,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C04038F2A7EAA41006ACFE8 /* libffi.a */,
5C0403902A7EAA41006ACFE8 /* libgmp.a */,
5C0403912A7EAA41006ACFE8 /* libgmpxx.a */,
5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */,
5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */,
6462EF782A8F4448003B2EAF /* libffi.a */,
6462EF762A8F4448003B2EAF /* libgmp.a */,
6462EF772A8F4448003B2EAF /* libgmpxx.a */,
6462EF752A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA-ghc8.10.7.a */,
6462EF792A8F4448003B2EAF /* libHSsimplex-chat-5.3.0.5-AGHrmoVFP0r7R9kWFmg3UA.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1478,7 +1478,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 168;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1520,7 +1520,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 168;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1600,7 +1600,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 168;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1632,7 +1632,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 168;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1664,7 +1664,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 167;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1688,7 +1688,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.2.2;
MARKETING_VERSION = 5.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1710,7 +1710,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 167;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1734,7 +1734,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.2.2;
MARKETING_VERSION = 5.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;

View File

@@ -1466,6 +1466,8 @@ public struct SecurityCode: Decodable, Equatable {
public struct UserContact: Decodable {
public var userContactLinkId: Int64
// public var connReqContact: String
public var groupId: Int64?
public init(userContactLinkId: Int64) {
self.userContactLinkId = userContactLinkId
@@ -1927,6 +1929,16 @@ public enum ConnectionEntity: Decodable {
return nil
}
}
public var ntfsEnabled: Bool {
switch self {
case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs ?? false
case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs
case .sndFileConnection: return false
case .rcvFileConnection: return false
case let .userContactConnection(userContact): return userContact.groupId == nil
}
}
}
public struct NtfMsgInfo: Decodable {
@@ -2009,6 +2021,17 @@ public struct ChatItem: Identifiable, Decodable {
}
}
public var memberConnected: GroupMember? {
switch chatDir {
case .groupRcv(let groupMember):
switch content {
case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember
default: return nil
}
default: return nil
}
}
private var showNtfDir: Bool {
return !chatDir.sent
}
@@ -2507,6 +2530,20 @@ public enum CIContent: Decodable, ItemContent {
}
}
}
public var showMemberName: Bool {
switch self {
case .rcvMsgContent: return true
case .rcvDeleted: return true
case .rcvCall: return true
case .rcvIntegrityError: return true
case .rcvDecryptionError: return true
case .rcvGroupInvitation: return true
case .rcvModerated: return true
case .invalidJSON: return true
default: return false
}
}
}
public enum MsgDecryptError: String, Decodable {

View File

@@ -83,6 +83,7 @@ android {
// Comma separated list of languages that will be included in the apk
android.defaultConfig.resConfigs(
"en",
"bg",
"cs",
"de",
"es",

View File

@@ -24,9 +24,8 @@
<application
android:name="SimplexApp"
android:allowBackup="true"
android:fullBackupOnly="true"
android:backupAgent="BackupAgent"
android:allowBackup="false"
android:fullBackupOnly="false"
android:icon="@mipmap/icon"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"

View File

@@ -1,18 +0,0 @@
package chat.simplex.app
import android.app.backup.BackupAgentHelper
import android.app.backup.FullBackupDataOutput
import android.content.Context
import chat.simplex.common.model.AppPreferences
import chat.simplex.common.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)
}
}
}

View File

@@ -137,3 +137,97 @@ buildConfig {
buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"")
}
}
afterEvaluate {
tasks.named("generateMRcommonMain") {
dependsOn("adjustFormatting")
}
tasks.create("adjustFormatting") {
doLast {
val debug = false
val stringRegex = Regex(".*<string .*</string>.*")
val startStringRegex = Regex("<string [^>]*>")
val endStringRegex = Regex("</string>[ ]*")
val endTagRegex = Regex("</")
val anyHtmlRegex = Regex("[^>]*>.*(<|>).*</string>|[^>]*>.*(&lt;|&gt;).*</string>")
val correctHtmlRegex = Regex("[^>]*>.*<b>.*</b>.*</string>|[^>]*>.*<i>.*</i>.*</string>|[^>]*>.*<u>.*</u>.*</string>|[^>]*>.*<font[^>]*>.*</font>.*</string>")
fun String.removeCDATA(): String =
if (contains("<![CDATA")) {
replace("<![CDATA[", "").replace("]]></string>", "</string>")
} else {
this
}
fun String.addCDATA(filepath: String): String {
//return this
if (anyHtmlRegex.matches(this)) {
val countOfStartTag = count { it == '<' }
val countOfEndTag = count { it == '>' }
if (countOfStartTag != countOfEndTag || countOfStartTag != endTagRegex.findAll(this).count() * 2 || !correctHtmlRegex.matches(this)) {
if (debug) {
println("Wrong string:")
println(this)
println("in $filepath")
println(" ")
} else {
throw Exception("Wrong string: $this \nin $filepath")
}
}
val res = replace(startStringRegex) { it.value + "<![CDATA[" }.replace(endStringRegex) { "]]>" + it.value }
if (debug) {
println("Changed string:")
println(this)
println(res)
println(" ")
}
return res
}
if (debug) {
println("Correct string:")
println(this)
println(" ")
}
return this
}
val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$")
kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath) }.asFileTree.forEach { file ->
val initialLines = ArrayList<String>()
val finalLines = ArrayList<String>()
file.useLines { lines ->
val multiline = ArrayList<String>()
lines.forEach { line ->
initialLines.add(line)
if (stringRegex.matches(line)) {
finalLines.add(line.removeCDATA().addCDATA(file.absolutePath))
} else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) {
multiline.add(line)
} else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) {
multiline.add(line)
finalLines.addAll(multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n"))
multiline.clear()
} else if (multiline.isNotEmpty()) {
multiline.add(line)
} else {
finalLines.add(line)
}
}
if (multiline.isNotEmpty()) {
throw Exception("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}")
}
}
if (!debug && finalLines != initialLines) {
file.writer().use {
finalLines.forEachIndexed { index, line ->
it.write(line)
if (index != finalLines.lastIndex) {
it.write("\n")
}
}
}
}
}
}
}
}

View File

@@ -39,14 +39,14 @@ private val sharedPreferencesThemes: SharedPreferences by lazy { androidAppConte
actual val settings: Settings by lazy { SharedPreferencesSettings(sharedPreferences) }
actual val settingsThemes: Settings by lazy { SharedPreferencesSettings(sharedPreferencesThemes) }
actual fun screenOrientation(): ScreenOrientation = when (mainActivity.get()?.resources?.configuration?.orientation) {
Configuration.ORIENTATION_PORTRAIT -> ScreenOrientation.PORTRAIT
Configuration.ORIENTATION_LANDSCAPE -> ScreenOrientation.LANDSCAPE
else -> ScreenOrientation.UNDEFINED
actual fun windowOrientation(): WindowOrientation = when (mainActivity.get()?.resources?.configuration?.orientation) {
Configuration.ORIENTATION_PORTRAIT -> WindowOrientation.PORTRAIT
Configuration.ORIENTATION_LANDSCAPE -> WindowOrientation.LANDSCAPE
else -> WindowOrientation.UNDEFINED
}
@Composable
actual fun screenWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun desktopExpandWindowToWidth(width: Dp) {}

View File

@@ -311,7 +311,6 @@ fun DesktopScreen(settingsState: SettingsViewState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
}
ModalManager.fullscreen.showInView()
ModalManager.fullscreen.showPasscodeInView()
}
}

View File

@@ -80,6 +80,7 @@ object ChatModel {
}
val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
val showChatPreviews by lazy { mutableStateOf(ChatController.appPrefs.privacyShowChatPreviews.get()) }
// current WebRTC call
val callManager = CallManager(this)
@@ -1385,6 +1386,18 @@ data class ChatItem (
else -> false
}
val memberConnected: GroupMember? get() =
when (chatDir) {
is CIDirection.GroupRcv -> when (content) {
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberConnected -> chatDir.groupMember
else -> null
}
else -> null
}
else -> null
}
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember>? {
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
val m = chatInfo.groupInfo.membership
@@ -1838,6 +1851,19 @@ sealed class CIContent: ItemContent {
is InvalidJSON -> "invalid data"
}
val showMemberName: Boolean get() =
when (this) {
is RcvMsgContent -> true
is RcvDeleted -> true
is RcvCall -> true
is RcvIntegrityError -> true
is RcvDecryptionError -> true
is RcvGroupInvitation -> true
is RcvModerated -> true
is InvalidJSON -> true
else -> false
}
companion object {
fun featureText(feature: Feature, enabled: String, param: Int?): String =
if (feature.hasParam) {

View File

@@ -91,8 +91,9 @@ class AppPreferences {
},
set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
)
val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true)
val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true)
val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false)
val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, false)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
@@ -245,6 +246,8 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews"
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
@@ -401,19 +404,15 @@ object ChatController {
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
if (cmd !is CC.ApiParseMarkdown) {
chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
}
chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
val json = chatSendCmd(ctrl, c)
val r = APIResponse.decodeStr(json)
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d(TAG, "sendCmd response json $json")
}
if (r.resp !is CR.ParsedMarkdown) {
chatModel.addTerminalItem(TerminalItem.resp(r.resp))
}
chatModel.addTerminalItem(TerminalItem.resp(r.resp))
r.resp
}
}
@@ -1290,13 +1289,6 @@ object ChatController {
}
}
suspend fun apiParseMarkdown(text: String): List<FormattedText>? {
val r = sendCmd(CC.ApiParseMarkdown(text))
if (r is CR.ParsedMarkdown) return r.formattedText
Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}")
return null
}
private fun networkErrorAlert(r: CR): Boolean {
return when {
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
@@ -1857,7 +1849,6 @@ sealed class CC {
class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
class ApiParseMarkdown(val text: String): CC()
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
class ApiCreateMyAddress(val userId: Long): CC()
@@ -1963,7 +1954,6 @@ sealed class CC {
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
is ApiParseMarkdown -> "/_parse $text"
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
is ApiCreateMyAddress -> "/_address $userId"
@@ -2058,7 +2048,6 @@ sealed class CC {
is ApiListContacts -> "apiListContacts"
is ApiUpdateProfile -> "apiUpdateProfile"
is ApiSetContactPrefs -> "apiSetContactPrefs"
is ApiParseMarkdown -> "apiParseMarkdown"
is ApiSetContactAlias -> "apiSetContactAlias"
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
is ApiCreateMyAddress -> "apiCreateMyAddress"
@@ -3340,7 +3329,6 @@ sealed class CR {
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: User, val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: User, val connection: PendingContactConnection): CR()
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: User?, val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val user_: User?, val chatError: ChatError): CR()
@@ -3465,7 +3453,6 @@ sealed class CR {
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted"
is VersionInfo -> "versionInfo"
is ParsedMarkdown -> "apiParsedMarkdown"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
@@ -3518,7 +3505,6 @@ sealed class CR {
is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")
is ParsedMarkdown -> json.encodeToString(formattedText)
is UserContactLink -> withUser(user, contactLink.responseDetails)
is UserContactLinkUpdated -> withUser(user, contactLink.responseDetails)
is UserContactLinkCreated -> withUser(user, connReqContact)

View File

@@ -14,7 +14,7 @@ interface RecorderInterface {
fun stop(): Int
}
expect class RecorderNative: RecorderInterface
expect class RecorderNative(): RecorderInterface
interface AudioPlayerInterface {
fun play(

View File

@@ -19,14 +19,14 @@ expect fun isInNightMode(): Boolean
expect val settings: Settings
expect val settingsThemes: Settings
enum class ScreenOrientation {
enum class WindowOrientation {
UNDEFINED, PORTRAIT, LANDSCAPE
}
expect fun screenOrientation(): ScreenOrientation
expect fun windowOrientation(): WindowOrientation
@Composable
expect fun screenWidth(): Dp
expect fun windowWidth(): Dp
expect fun desktopExpandWindowToWidth(width: Dp)

View File

@@ -38,6 +38,7 @@ import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
@Composable
@@ -51,15 +52,16 @@ fun ChatInfoView(
close: () -> Unit,
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val currentUser = chatModel.currentUser.value
val connStats = remember { mutableStateOf(connectionStats) }
val contact = rememberUpdatedState(contact).value
val chat = remember(contact.id) { chatModel.chats.firstOrNull { it.id == contact.id } }
val currentUser = remember { chatModel.currentUser }.value
val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null && currentUser != null) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) {
mutableStateOf(chatModel.contactNetworkStatus(contact))
}
val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
ChatInfoLayout(
chat,
contact,
@@ -203,7 +205,10 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() ->
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
@@ -239,7 +244,7 @@ fun ChatInfoLayout(
currentUser: User,
sendReceipts: State<SendReceipts>,
setSendReceipts: (SendReceipts) -> Unit,
connStats: MutableState<ConnectionStats?>,
connStats: State<ConnectionStats?>,
contactNetworkStatus: NetworkStatus,
customUserProfile: Profile?,
localAlias: String,
@@ -256,10 +261,15 @@ fun ChatInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
KeyChangeEffect(chat.id) {
scope.launch { scrollState.scrollTo(0) }
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
) {
Row(
Modifier.fillMaxWidth(),
@@ -400,6 +410,7 @@ fun LocalAliasEditor(
updateValue: (String) -> Unit
) {
var value by rememberSaveable { mutableStateOf(initialValue) }
var updatedValueAtLeastOnce = remember { false }
val modifier = if (center)
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
else
@@ -424,19 +435,23 @@ fun LocalAliasEditor(
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
) {
value = it
updatedValueAtLeastOnce = true
}
}
LaunchedEffect(Unit) {
var prevValue = value
snapshotFlow { value }
.distinctUntilChanged()
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
.conflate() // get the latest value
.filter { it == value } // don't process old ones
.filter { it == value && it != prevValue } // don't process old ones
.collect {
updateValue(value)
updateValue(it)
prevValue = it
}
}
DisposableEffect(Unit) {
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
onDispose { if (updatedValueAtLeastOnce) updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
}
}

View File

@@ -17,11 +17,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
@@ -66,11 +66,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (activeChat.value?.id != chatModel.chatId.value && chatModel.chatId.value != null) {
.collect { chatId ->
if (activeChat.value?.id != chatId && chatId != null) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatModel.chatId.value!!)
activeChat.value = chatModel.getChat(chatId)
}
markUnreadChatAsRead(activeChat, chatModel)
}
@@ -98,13 +98,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val view = LocalMultiplatformView()
if (activeChat.value == null || user == null) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
} else {
val chat = activeChat.value!!
// 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 == chatId }?.chatStats?.unreadCount ?: 0
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
}
}
val clipboard = LocalClipboardManager.current
@@ -133,27 +134,45 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.chatId.value = null
},
info = {
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
hideKeyboard(view)
withApi {
// The idea is to preload information before showing a modal because large groups can take time to load all members
var preloadedContactInfo: Pair<ConnectionStats, Profile?>? = null
var preloadedCode: String? = null
var preloadedLink: Pair<String, GroupMemberRole>? = null
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { 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)
}
}
preloadedContactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
preloadedCode = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId).second
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
var groupLink = link?.first
var groupLinkMemberRole = link?.second
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
groupLink = it.first;
groupLinkMemberRole = it.second
preloadedLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
}
ModalManager.end.showModalCloseable(true) { close ->
val chat = remember { activeChat }.value
if (chat?.chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId).second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
setGroupMembers((chat.chatInfo as ChatInfo.Group).groupInfo, chatModel)
link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatModel, link?.first, link?.second, {
link = it
preloadedLink = it
}, close)
}
}
@@ -699,7 +718,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
)
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { 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
@@ -741,27 +760,73 @@ fun BoxWithConstraintsScope.ChatItemsList(
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp).then(swipeableModifier)) {
if (showMember) {
Box(
Modifier
.clip(CircleShape)
.clickable {
showMemberInfo(chat.chatInfo.groupInfo, member)
}
) {
MemberImage(member)
val nextItem = if (i - 1 >= 0) reversedChatItems[i - 1] else null
fun getConnectedMemberNames(): List<String> {
val ns = mutableListOf<String>()
var idx = i
while (idx < reversedChatItems.size) {
val m = reversedChatItems[idx].memberConnected
if (m != null) {
ns.add(m.displayName)
} else {
break
}
idx++
}
return ns
}
if (cItem.memberConnected != null && nextItem?.memberConnected != null) {
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
Box(Modifier.size(0.dp)) {}
} else {
val member = cItem.chatDir.groupMember
if (showMemberImage(member, prevItem)) {
Column(
Modifier
.padding(top = 8.dp)
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start
) {
if (cItem.content.showMemberName) {
Text(
member.displayName,
Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp),
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
}
Row(
swipeableModifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
Modifier
.clip(CircleShape)
.clickable {
showMemberInfo(chat.chatInfo.groupInfo, member)
}
) {
MemberImage(member)
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames)
}
}
} else {
Row(
Modifier
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
.then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames)
}
Spacer(Modifier.size(4.dp))
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
}
} else {
Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) {
Box(
Modifier
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
.then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
}
}
@@ -777,7 +842,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
if (cItem.isRcvNew) {
if (cItem.isRcvNew && chat.id == ChatModel.chatId.value) {
LaunchedEffect(cItem.id) {
scope.launch {
delay(600)
@@ -909,17 +974,19 @@ fun BoxWithConstraintsScope.FloatingButtons(
onLongClick = { showDropDown.value = true }
)
DefaultDropdownMenu(showDropDown, offset = DpOffset(maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
ItemAction(
generalGetString(MR.strings.mark_read),
painterResource(MR.images.ic_check),
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown.value = false
})
Box {
DefaultDropdownMenu(showDropDown, offset = DpOffset(this@FloatingButtons.maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
ItemAction(
generalGetString(MR.strings.mark_read),
painterResource(MR.images.ic_check),
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown.value = false
})
}
}
}
@@ -954,9 +1021,11 @@ fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
(prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
}
val MEMBER_IMAGE_SIZE: Dp = 38.dp
@Composable
fun MemberImage(member: GroupMember) {
ProfileImage(38.dp, member.memberProfile.image)
ProfileImage(MEMBER_IMAGE_SIZE, member.memberProfile.image)
}
@Composable

View File

@@ -16,6 +16,8 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.Indigo
import chat.simplex.common.ui.theme.isSystemInDarkTheme
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
@@ -112,7 +114,7 @@ data class ComposeState(
}
val empty: Boolean
get() = message.isEmpty() && preview is ComposePreview.NoPreview
get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
@@ -230,6 +232,7 @@ fun ComposeView(
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val saveLastDraft = chatModel.controller.appPrefs.privacySaveLastDraft.get()
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
@@ -240,7 +243,7 @@ fun ComposeView(
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
fun parseMessage(msg: String): String? {
val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
val parsedMsg = parseToMarkdown(msg)
val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
return link?.text
}
@@ -283,6 +286,20 @@ fun ComposeView(
cancelledLinks.clear()
}
fun clearPrevDraft(prevChatId: String?) {
if (chatModel.draftChatId.value == prevChatId) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
}
fun clearCurrentDraft() {
if (chatModel.draftChatId.value == chat.id) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
}
fun clearState(live: Boolean = false) {
if (live) {
composeState.value = composeState.value.copy(inProgress = false)
@@ -378,6 +395,7 @@ fun ComposeView(
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
sending()
}
clearCurrentDraft()
if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem
@@ -705,13 +723,6 @@ fun ComposeView(
}
}
fun clearCurrentDraft() {
if (chatModel.draftChatId.value == chat.id) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
}
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
if (!chat.userCanSend) {
clearCurrentDraft()
@@ -719,29 +730,38 @@ fun ComposeView(
}
}
DisposableEffectOnGone {
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearCurrentDraft()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (composeState.value.inProgress) {
clearCurrentDraft()
} else if (!composeState.value.empty) {
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearCurrentDraft()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
if (chat.chatInfo.incognito)
if (isSystemInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
else MaterialTheme.colors.primary
SendMsgView(
composeState,
showVoiceRecordIcon = true,
@@ -753,6 +773,7 @@ fun ComposeView(
allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = userIsObserver.value,
userCanSend = userCanSend.value,
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
sendMessage = { ttl ->

View File

@@ -11,7 +11,9 @@ import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.model.*
@@ -27,6 +29,17 @@ fun ContextItemView(
val sent = contextItem.chatDir.sent
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@Composable
fun msgContentView(lines: Int) {
MarkdownText(
contextItem.text, contextItem.formattedText,
maxLines = lines,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
}
Row(
Modifier
.padding(top = 8.dp)
@@ -49,12 +62,21 @@ fun ContextItemView(
contentDescription = stringResource(MR.strings.icon_descr_context),
tint = MaterialTheme.colors.secondary,
)
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
val sender = contextItem.memberDisplayName
if (sender != null) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
sender,
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
msgContentView(lines = 2)
}
} else {
msgContentView(lines = 3)
}
}
IconButton(onClick = cancelContextItem) {
Icon(

View File

@@ -41,6 +41,7 @@ fun SendMsgView(
allowedVoiceByPrefs: Boolean,
userIsObserver: Boolean,
userCanSend: Boolean,
sendButtonColor: Color = MaterialTheme.colors.primary,
allowVoiceToContact: () -> Unit,
timedMessageAllowed: Boolean = false,
customDisappearingMessageTimePref: SharedPreference<Int>? = null,
@@ -64,12 +65,22 @@ fun SendMsgView(
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview)
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(composeState.value.inProgress) {
progressByTimeout = if (composeState.value.inProgress) {
delay(500)
composeState.value.inProgress
} else {
false
}
}
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
PlatformTextField(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage) {
sendMessage(null)
if (!cs.inProgress) {
sendMessage(null)
}
}
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) {
@@ -98,7 +109,7 @@ fun SendMsgView(
}
}
when {
showProgress -> ProgressIndicator()
progressByTimeout -> ProgressIndicator()
showVoiceButton -> {
Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) }
@@ -184,12 +195,12 @@ fun SendMsgView(
val menuItems = MenuItems()
if (menuItems.isNotEmpty()) {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true }
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) { showDropdown.value = true }
DefaultDropdownMenu(showDropdown) {
menuItems.forEach { composable -> composable() }
}
} else {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage)
}
}
}
@@ -439,6 +450,7 @@ private fun SendMsgButton(
icon: Painter,
sizeDp: Animatable<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
sendButtonColor: Color,
enabled: Boolean,
sendMessage: (Int?) -> Unit,
onLongClick: (() -> Unit)? = null
@@ -466,7 +478,7 @@ private fun SendMsgButton(
.padding(4.dp)
.alpha(alpha.value)
.clip(CircleShape)
.background(if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary)
.background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary)
.padding(3.dp)
)
}

View File

@@ -71,6 +71,9 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
close = close,
)
KeyChangeEffect(chatModel.chatId.value) {
close()
}
}
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
@@ -173,7 +176,7 @@ fun AddGroupMembersLayout(
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.select_contacts)) {
SectionItemView(padding = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText, selectedContacts.size)
SearchRowView(searchText)
}
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
@@ -184,8 +187,7 @@ fun AddGroupMembersLayout(
@Composable
private fun SearchRowView(
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) },
selectedContactsSize: Int
searchText: MutableState<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
) {
Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) {
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.secondary)
@@ -194,11 +196,6 @@ private fun SearchRowView(
SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) {
searchText.value = searchText.value.copy(it)
}
val view = LocalMultiplatformView()
LaunchedEffect(selectedContactsSize) {
searchText.value = searchText.value.copy("")
hideKeyboard(view)
}
}
@Composable

View File

@@ -8,8 +8,8 @@ import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@@ -33,11 +33,12 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val currentUser = chatModel.currentUser.value
@@ -132,7 +133,10 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
@@ -177,79 +181,92 @@ fun GroupChatInfoLayout(
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
) {
Column(
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
KeyChangeEffect(chat.id) {
scope.launch { listState.scrollToItem(0) }
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = remember(members) { derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim()) } } }
LazyColumn(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
state = listState
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
}
if (groupInfo.groupProfile.description != null || groupInfo.canEdit) {
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
SendReceiptsOptionDisabled()
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
}
if (groupInfo.groupProfile.description != null || groupInfo.canEdit) {
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
GroupLinkButton(manageGroupLink)
}
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
AddMembersButton(tint, onAddMembersClick)
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = remember { derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim()) } } }
if (members.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
SendReceiptsOptionDisabled()
}
}
SectionItemView(minHeight = 54.dp) {
MemberRow(groupInfo.membership, user = true)
}
MembersList(filteredMembers.value, showMemberInfo)
}
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
DeleteGroupButton(deleteGroup)
}
if (groupInfo.membership.memberCurrent) {
LeaveGroupButton(leaveGroup)
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName)
InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString())
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
} else {
GroupLinkButton(manageGroupLink)
}
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
AddMembersButton(tint, onAddMembersClick)
}
if (members.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
}
}
SectionItemView(minHeight = 54.dp) {
MemberRow(groupInfo.membership, user = true)
}
}
}
SectionBottomSpacer()
items(filteredMembers.value) { member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
}
}
item {
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
DeleteGroupButton(deleteGroup)
}
if (groupInfo.membership.memberCurrent) {
LeaveGroupButton(leaveGroup)
}
}
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName)
InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString())
}
}
SectionBottomSpacer()
}
}
}
@@ -330,18 +347,6 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick
)
}
@Composable
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
}
}
}
}
@Composable
private fun MemberRow(member: GroupMember, user: Boolean = false) {
Row(

View File

@@ -23,7 +23,7 @@ import chat.simplex.common.views.newchat.QRCode
import chat.simplex.res.MR
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
var creatingLink by rememberSaveable { mutableStateOf(false) }
@@ -34,7 +34,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
onGroupLinkUpdated(link)
}
creatingLink = false
}
@@ -60,7 +60,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
onGroupLinkUpdated(link)
}
}
}
@@ -75,7 +75,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
onGroupLinkUpdated(null to null)
onGroupLinkUpdated(null)
}
}
},

View File

@@ -8,43 +8,19 @@ 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.common.model.ChatItem
import chat.simplex.common.ui.theme.*
@Composable
fun CIEventView(ci: ChatItem) {
@Composable
fun chatEventTextView(text: AnnotatedString) {
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
fun CIEventView(text: AnnotatedString) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
chatEventTextView(
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}.plus(chatEventText(ci))
)
} else {
chatEventTextView(chatEventText(ci))
}
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
}
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
fun chatEventText(ci: ChatItem): AnnotatedString =
buildAnnotatedString {
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
@@ -52,8 +28,6 @@ fun chatEventText(ci: ChatItem): AnnotatedString =
@Composable
fun CIEventViewPreview() {
SimpleXTheme {
CIEventView(
ChatItem.getGroupEventSample()
)
CIEventView(buildAnnotatedString { append("event happened") })
}
}

View File

@@ -32,7 +32,6 @@ fun CIRcvDecryptionError(
syncMemberConnection: (GroupInfo, GroupMember) -> Unit,
findModelChat: (String) -> Chat?,
findModelMember: (String) -> GroupMember?,
showMember: Boolean
) {
LaunchedEffect(Unit) {
if (cInfo is ChatInfo.Direct) {
@@ -46,7 +45,6 @@ fun CIRcvDecryptionError(
fun BasicDecryptionErrorItem() {
DecryptionErrorItem(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.decryption_error),
@@ -64,7 +62,6 @@ fun CIRcvDecryptionError(
if (modelContactStats.ratchetSyncAllowed) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.fix_connection_question),
@@ -78,7 +75,6 @@ fun CIRcvDecryptionError(
} else if (!modelContactStats.ratchetSyncSupported) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.fix_connection_not_supported_by_contact),
@@ -103,7 +99,6 @@ fun CIRcvDecryptionError(
if (modelMemberStats.ratchetSyncAllowed) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.fix_connection_question),
@@ -117,7 +112,6 @@ fun CIRcvDecryptionError(
} else if (!modelMemberStats.ratchetSyncSupported) {
DecryptionErrorItemFixButton(
ci,
showMember,
onClick = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.fix_connection_not_supported_by_group_member),
@@ -140,7 +134,6 @@ fun CIRcvDecryptionError(
@Composable
fun DecryptionErrorItemFixButton(
ci: ChatItem,
showMember: Boolean,
onClick: () -> Unit,
syncSupported: Boolean
) {
@@ -159,7 +152,6 @@ fun DecryptionErrorItemFixButton(
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
@@ -189,7 +181,6 @@ fun DecryptionErrorItemFixButton(
@Composable
fun DecryptionErrorItem(
ci: ChatItem,
showMember: Boolean,
onClick: () -> Unit
) {
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@@ -204,7 +195,6 @@ fun DecryptionErrorItem(
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) }
},

View File

@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -29,13 +29,22 @@ import kotlinx.datetime.Clock
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
fun chatEventText(ci: ChatItem): AnnotatedString =
chatEventText(ci.content.text, ci.timestampText)
fun chatEventText(eventText: String, ts: String): AnnotatedString =
buildAnnotatedString {
withStyle(chatEventStyle) { append("$eventText $ts") }
}
@Composable
fun ChatItemView(
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
deleteMessage: (Long, CIDeleteMode) -> Unit,
@@ -53,6 +62,7 @@ fun ChatItemView(
findModelMember: (String) -> GroupMember?,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
getConnectedMemberNames: (() -> List<String>)? = null,
) {
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
@@ -95,7 +105,8 @@ fun ChatItemView(
ReactionIcon(r.reaction.text, fontSize = 12.sp)
if (r.totalReacted > 1) {
Spacer(Modifier.width(4.dp))
Text("${r.totalReacted}",
Text(
"${r.totalReacted}",
fontSize = 11.5.sp,
fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal,
color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
@@ -116,7 +127,7 @@ fun ChatItemView(
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
@@ -246,7 +257,7 @@ fun ChatItemView(
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
@@ -265,7 +276,7 @@ fun ChatItemView(
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DeletedItemView(cItem, cInfo.timedMessagesTTL)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
@@ -276,9 +287,48 @@ fun ChatItemView(
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
fun eventItemViewText(): AnnotatedString {
val memberDisplayName = cItem.memberDisplayName
return if (memberDisplayName != null) {
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}.plus(chatEventText(cItem))
} else {
chatEventText(cItem)
}
}
@Composable fun EventItemView() {
CIEventView(eventItemViewText())
}
fun membersConnectedText(): String? {
return if (getConnectedMemberNames != null) {
val ns = getConnectedMemberNames()
when {
ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2)
ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2])
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
else -> null
}
} else {
null
}
}
fun membersConnectedItemText(): AnnotatedString {
val t = membersConnectedText()
return if (t != null) {
chatEventText(t, cItem.timestampText)
} else {
eventItemViewText()
}
}
@Composable
fun ModeratedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage)
@@ -292,14 +342,17 @@ 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(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, showMember = showMember)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember)
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.RcvGroupEventContent -> when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText())
else -> EventItemView()
}
is CIContent.SndGroupEventContent -> EventItemView()
is CIContent.RcvConnEventContent -> EventItemView()
is CIContent.SndConnEventContent -> EventItemView()
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {

View File

@@ -16,7 +16,7 @@ import chat.simplex.common.model.ChatItem
import chat.simplex.common.ui.theme.*
@Composable
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
val sent = ci.chatDir.sent
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
@@ -30,7 +30,6 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean =
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),

View File

@@ -20,9 +20,11 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE
import chat.simplex.res.MR
import kotlin.math.min
@@ -32,7 +34,6 @@ fun FramedItemView(
ci: ChatItem,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
@@ -49,17 +50,39 @@ fun FramedItemView(
@Composable
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
@Composable
fun ciQuotedMsgTextView(qi: CIQuote, lines: Int) {
MarkdownText(
qi.text,
qi.formattedText,
maxLines = lines,
overflow = TextOverflow.Ellipsis,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
linkMode = linkMode,
uriHandler = if (appPlatform.isDesktop) uriHandler else null
)
}
@Composable
fun ciQuotedMsgView(qi: CIQuote) {
Box(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart
) {
MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
linkMode = linkMode
)
val sender = qi.sender(membership())
if (sender != null) {
Column(
horizontalAlignment = Alignment.Start
) {
Text(
sender,
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
ciQuotedMsgTextView(qi, lines = 2)
}
} else {
ciQuotedMsgTextView(qi, lines = 3)
}
}
}
@@ -156,7 +179,7 @@ fun FramedItemView(
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)
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler)
}
}
@@ -207,7 +230,7 @@ fun FramedItemView(
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
}
}
is MsgContent.MCVideo -> {
@@ -215,29 +238,29 @@ fun FramedItemView(
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
}
}
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick)
}
}
}
@@ -253,7 +276,6 @@ fun FramedItemView(
fun CIMarkdownText(
ci: ChatItem,
chatTTL: Int?,
showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
@@ -261,7 +283,7 @@ fun CIMarkdownText(
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(
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
text, if (text.isEmpty()) emptyList() else ci.formattedText,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)

View File

@@ -24,8 +24,8 @@ import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@Composable
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
CIMsgError(ci, timedMessagesTTL, showMember) {
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?) {
CIMsgError(ci, timedMessagesTTL) {
when (msgError) {
is MsgErrorType.MsgSkipped ->
AlertManager.shared.showAlertMsg(
@@ -50,7 +50,7 @@ fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTT
}
@Composable
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false, onClick: () -> Unit) {
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Surface(
Modifier.clickable(onClick = onClick),
@@ -63,7 +63,6 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false
) {
Text(
buildAnnotatedString {
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),

View File

@@ -20,7 +20,7 @@ import chat.simplex.res.MR
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Surface(
@@ -47,7 +47,6 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Bool
private fun MarkedDeletedText(text: String) {
Text(
buildAnnotatedString {
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),

View File

@@ -1,39 +1,32 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.text.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
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.input.pointer.*
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.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.*
import java.awt.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
if (chatItem.chatDir is CIDirection.GroupRcv) {
val name = chatItem.chatDir.groupMember.memberProfile.displayName
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
else b.append(name)
b.append(": ")
}
}
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
if (sender != null) {
if (senderBold) b.withStyle(boldFont) { append(sender) }
@@ -165,7 +158,8 @@ fun MarkdownText (
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
val icon = remember { mutableStateOf(PointerIcon.Default) }
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
@@ -188,6 +182,15 @@ fun MarkdownText (
uriHandler.openVerifiedSimplexUri(annotation.item)
}
},
onHover = { offset ->
icon.value = annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let {
PointerIcon.Hand
} ?: annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let {
PointerIcon.Hand
} ?: PointerIcon.Default
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset).any()
@@ -211,6 +214,7 @@ fun ClickableText(
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
onLongClick: (Int) -> Unit = {},
onHover: (Int) -> Unit = {},
shouldConsumeEvent: (Int) -> Boolean
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -234,6 +238,14 @@ fun ClickableText(
consume
}
)
}.pointerInput(onHover) {
if (appPlatform.isDesktop) {
detectCursorMove { pos ->
layoutResult.value?.let { layoutResult ->
onHover(layoutResult.getOffsetForPosition(pos))
}
}
}
}
BasicText(

View File

@@ -44,11 +44,12 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
showMenu.value = false
delay(500L)
}
val showChatPreviews = chatModel.showChatPreviews.value
when (chat.chatInfo) {
is ChatInfo.Direct -> {
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -57,7 +58,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -103,7 +104,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
if (chatInfo.ready) {
withApi { openChat(chatInfo, chatModel) }
withBGApi { openChat(chatInfo, chatModel) }
} else {
pendingContactAlertDialog(chatInfo, chatModel)
}
@@ -113,7 +114,7 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
}
}
@@ -368,7 +369,12 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
stringResource(MR.strings.delete_verb),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {
if (chatModel.chatId.value == null) {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
}
}
showMenu.value = false
},
color = Color.Red
@@ -517,7 +523,10 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
}
}
},
@@ -550,7 +559,10 @@ fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
if (r) {
chatModel.removeChat(groupInfo.id)
chatModel.chatId.value = null
if (chatModel.chatId.value == groupInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(groupInfo.id)
}
}
@@ -657,6 +669,7 @@ fun PreviewChatListNavLinkDirect() {
),
chatStats = Chat.ChatStats()
),
true,
null,
null,
null,
@@ -696,6 +709,7 @@ fun PreviewChatListNavLinkGroup() {
),
chatStats = Chat.ChatStats()
),
true,
null,
null,
null,

View File

@@ -61,6 +61,13 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
connectIfOpenedViaUri(url, chatModel)
}
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst()
}
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope()

View File

@@ -31,6 +31,7 @@ import dev.icerock.moko.resources.ImageResource
@Composable
fun ChatPreviewView(
chat: Chat,
showChatPreviews: Boolean,
chatModelDraft: ComposeState?,
chatModelDraftChatId: ChatId?,
currentUserProfileDisplayName: String?,
@@ -140,32 +141,34 @@ fun ChatPreviewView(
fun chatPreviewText() {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
ci.meta.itemDeleted == null -> ci.text to null
else -> generalGetString(MR.strings.marked_deleted_description) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
}
MarkdownText(
text,
formattedText,
sender = when {
if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) {
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
ci.meta.itemDeleted == null -> ci.text to null
else -> generalGetString(MR.strings.marked_deleted_description) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
},
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
)
}
MarkdownText(
text,
formattedText,
sender = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
else -> null
},
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
)
}
} else {
when (cInfo) {
is ChatInfo.Direct ->
@@ -336,6 +339,6 @@ fun unreadCountStr(n: Int): String {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
}
}

View File

@@ -93,7 +93,7 @@ fun UserPicker(
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { screenWidth() * density }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Box(Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
@@ -201,7 +201,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
fun UserProfileRow(u: User) {
Row(
Modifier
.widthIn(max = screenWidth() * 0.7f)
.widthIn(max = windowWidth() * 0.7f)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -78,7 +78,6 @@ fun DatabaseView(
chatArchiveName,
chatArchiveTime,
chatLastStart,
m.controller.appPrefs.privacyFullBackup,
appFilesCountAndSize,
chatItemTTL,
m.currentUser.value,
@@ -128,7 +127,6 @@ fun DatabaseLayout(
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
privacyFullBackup: SharedPreference<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
currentUser: User?,
@@ -168,6 +166,13 @@ fun DatabaseLayout(
SectionView(stringResource(MR.strings.run_chat_section)) {
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
}
SectionTextFooter(
if (stopped) {
stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(MR.strings.stop_chat_to_enable_database_actions)
}
)
SectionDividerSpaced()
SectionView(stringResource(MR.strings.chat_database_section)) {
@@ -180,8 +185,6 @@ fun DatabaseLayout(
iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary,
disabled = operationsDisabled
)
AppDataBackupPreference(privacyFullBackup, initialRandomDBPassphrase)
SectionDividerSpaced(maxBottomPadding = false)
SettingsActionItem(
painterResource(MR.images.ic_ios_share),
stringResource(MR.strings.export_database),
@@ -225,13 +228,6 @@ fun DatabaseLayout(
disabled = operationsDisabled
)
}
SectionTextFooter(
if (stopped) {
stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(MR.strings.stop_chat_to_enable_database_actions)
}
)
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) {
@@ -258,23 +254,6 @@ fun DatabaseLayout(
}
}
@Composable
private fun AppDataBackupPreference(privacyFullBackup: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>) {
SettingsPreferenceItem(
painterResource(MR.images.ic_backup),
iconColor = MaterialTheme.colors.secondary,
pref = privacyFullBackup,
text = stringResource(MR.strings.full_backup)
) {
if (initialRandomDBPassphrase.get()) {
exportProhibitedAlert()
privacyFullBackup.set(false)
} else {
privacyFullBackup.set(it)
}
}
}
private fun setChatItemTTLAlert(
m: ChatModel, selectedChatItemTTL: MutableState<ChatItemTTL>,
progressIndicator: MutableState<Boolean>,
@@ -683,7 +662,6 @@ fun PreviewDatabaseLayout() {
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
privacyFullBackup = SharedPreference({ true }, {}),
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
currentUser = User.sampleData,

View File

@@ -8,6 +8,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
@@ -51,26 +52,31 @@ class AlertManager {
buttons: @Composable () -> Unit,
) {
showAlert {
DefaultDialog(onDismissRequest = ::hideAlert) {
Column(
Modifier
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
.padding(bottom = DEFAULT_PADDING)
) {
AlertDialog(
onDismissRequest = ::hideAlert,
title = {
Text(
title,
Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING),
textAlign = TextAlign.Center,
fontSize = 20.sp
)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) {
Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary)
},
buttons = {
Column(
Modifier
.padding(bottom = DEFAULT_PADDING)
) {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
if (text != null) {
Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary)
}
buttons()
}
buttons()
}
}
}
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
)
}
}
@@ -154,13 +160,22 @@ class AlertManager {
title = alertTitle(title),
text = alertText(text),
buttons = {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Row(
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
horizontalArrangement = Arrangement.Center
) {
TextButton(onClick = {
hideAlert()
}) { Text(confirmText, color = Color.Unspecified) }
TextButton(
onClick = {
hideAlert()
},
Modifier.focusRequester(focusRequester)
) {
Text(confirmText, color = Color.Unspecified)
}
}
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))

View File

@@ -92,6 +92,17 @@ suspend fun PointerInputScope.detectGesture(
}
}
suspend fun PointerInputScope.detectCursorMove(onMove: (Offset) -> Unit = {},) = coroutineScope {
forEachGesture {
awaitPointerEventScope {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Move) {
onMove(event.changes[0].position)
}
}
}
}
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()

View File

@@ -97,6 +97,12 @@ class ModalManager(private val placement: ModalPlacement? = null) {
modalCount.value = 0
}
fun closeModalsExceptFirst() {
while (modalCount.value > 1) {
closeModal()
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun showInView() {

View File

@@ -12,7 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.screenWidth
import chat.simplex.common.platform.windowWidth
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
@@ -238,7 +238,7 @@ fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color
@Composable
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
SectionItemViewSpaceBetween(onClick) {
val screenWidthDp = screenWidth()
val screenWidthDp = windowWidth()
Text(title)
Text(
value,

View File

@@ -306,10 +306,10 @@ fun IntSize.Companion.Saver(): Saver<IntSize, *> = Saver(
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
DisposableEffect(Unit) {
always()
val orientation = screenOrientation()
val orientation = windowOrientation()
onDispose {
whenDispose()
if (orientation == screenOrientation()) {
if (orientation == windowOrientation()) {
whenGone()
}
}
@@ -320,12 +320,53 @@ fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}
fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) {
DisposableEffect(Unit) {
always()
val orientation = screenOrientation()
val orientation = windowOrientation()
onDispose {
whenDispose()
if (orientation != screenOrientation()) {
if (orientation != windowOrientation()) {
whenRotate()
}
}
}
}
/**
* Runs the [block] only after initial value of the [key1] changes, not after initial launch
* */
@Composable
@NonRestartableComposable
fun <T> KeyChangeEffect(
key1: T?,
block: suspend CoroutineScope.(prevKey: T?) -> Unit
) {
var prevKey by remember { mutableStateOf(key1) }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != prevKey) {
block(prevKey)
prevKey = key1
anyChange = true
}
}
}
/**
* Runs the [block] only after initial value of the [key1] or [key2] changes, not after initial launch
* */
@Composable
@NonRestartableComposable
fun KeyChangeEffect(
key1: Any?,
key2: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val initialKey = remember { key1 }
val initialKey2 = remember { key2 }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != initialKey || key2 != initialKey2) {
block()
anyChange = true
}
}
}

View File

@@ -6,10 +6,11 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.input.key.*
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.ScreenOrientation
import chat.simplex.common.platform.screenOrientation
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.common.views.helpers.*
@@ -25,9 +26,43 @@ fun PasscodeView(
submit: () -> Unit,
cancel: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
@Composable
fun Modifier.handleKeyboard(): Modifier {
val numbers = remember {
arrayOf(
Key.Zero, Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine,
Key.NumPad0, Key.NumPad1, Key.NumPad2, Key.NumPad3, Key.NumPad4, Key.NumPad5, Key.NumPad6, Key.NumPad7, Key.NumPad8, Key.NumPad9
)
}
return onPreviewKeyEvent {
if (it.key in numbers && it.type == KeyEventType.KeyDown) {
if (passcode.value.length < 16) {
passcode.value += numbers.indexOf(it.key) % 10
}
true
} else if (it.key == Key.Backspace && it.type == KeyEventType.KeyDown && (it.isCtrlPressed || it.isMetaPressed)) {
passcode.value = ""
true
} else if (it.key == Key.Backspace && it.type == KeyEventType.KeyDown) {
passcode.value = passcode.value.dropLast(1)
true
} else if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) {
if ((submitEnabled?.invoke(passcode.value) != false && passcode.value.length >= 4)) {
submit()
}
true
} else {
false
}
}
}
@Composable
fun VerticalLayout() {
Column(
Modifier.handleKeyboard().focusRequester(focusRequester),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
@@ -38,7 +73,7 @@ fun PasscodeView(
}
}
PasscodeEntry(passcode, true)
Row {
Row(Modifier.heightIn(min = 70.dp), verticalAlignment = Alignment.CenterVertically) {
SimpleButton(generalGetString(MR.strings.cancel_verb), icon = painterResource(MR.images.ic_close), click = cancel)
Spacer(Modifier.size(20.dp))
SimpleButton(submitLabel, icon = painterResource(MR.images.ic_done_filled), disabled = submitEnabled?.invoke(passcode.value) == false || passcode.value.length < 4, click = submit)
@@ -48,9 +83,9 @@ fun PasscodeView(
@Composable
fun HorizontalLayout() {
Row(Modifier.padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.Center) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING).handleKeyboard().focusRequester(focusRequester), horizontalArrangement = Arrangement.Center) {
Column(
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING * 4),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
@@ -64,7 +99,7 @@ fun PasscodeView(
}
Column(
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING * 4),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
@@ -90,9 +125,14 @@ fun PasscodeView(
}
}
if (screenOrientation() == ScreenOrientation.PORTRAIT) {
if (windowOrientation() == WindowOrientation.PORTRAIT || appPlatform.isDesktop) {
VerticalLayout()
} else {
HorizontalLayout()
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
// Disallow to steal a focus by clicking on buttons or using Tab
focusRequester.captureFocus()
}
}

View File

@@ -1,7 +1,6 @@
package chat.simplex.common.views.localauth
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -16,6 +15,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
@Composable
@@ -39,7 +39,7 @@ fun PasscodeEntry(
fun PasscodeView(password: MutableState<String>) {
var showPasscode by rememberSaveable { mutableStateOf(false) }
Text(
if (password.value.isEmpty()) " " else remember(password.value, showPasscode) { splitPassword(showPasscode, password.value) },
if (password.value.isEmpty()) "" else remember(password.value, showPasscode) { splitPassword(showPasscode, password.value) },
Modifier.padding(vertical = 10.dp).clickable { showPasscode = !showPasscode },
style = MaterialTheme.typography.body1
)
@@ -47,7 +47,7 @@ fun PasscodeView(password: MutableState<String>) {
@Composable
private fun BoxWithConstraintsScope.VerticalPasswordGrid(password: MutableState<String>) {
val s = minOf(maxWidth, maxHeight) / 4 - 1.dp
val s = if (appPlatform.isAndroid) minOf(maxWidth, maxHeight) / 4 - 1.dp else minOf(minOf(maxWidth, maxHeight) / 4 - 1.dp, 100.dp)
Column(Modifier.width(IntrinsicSize.Min)) {
DigitsRow(s, 1, 2, 3, password)
Divider()

View File

@@ -97,7 +97,7 @@ private fun NewChatSheetLayout(
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val maxWidth = with(LocalDensity.current) { screenWidth() * density }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Column(
Modifier
.fillMaxSize()

View File

@@ -23,6 +23,7 @@ import chat.simplex.common.views.usersettings.IncognitoView
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import java.net.URI
import java.net.URISyntaxException
@Composable
fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
@@ -73,6 +74,11 @@ fun PasteToConnectLayout(
title = generalGetString(MR.strings.invalid_connection_link),
text = generalGetString(MR.strings.this_string_is_not_a_connection_link)
)
} catch (e: URISyntaxException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_connection_link),
text = generalGetString(MR.strings.this_string_is_not_a_connection_link)
)
}
}

View File

@@ -201,6 +201,7 @@ object AppearanceScope {
val supportedLanguages = mapOf(
"system" to generalGetString(MR.strings.language_system),
"en" to "English",
"bg" to "Български",
"cs" to "Čeština",
"de" to "Deutsch",
"es" to "Español",
@@ -213,7 +214,7 @@ object AppearanceScope {
"ru" to "Русский",
"zh-CN" to "简体中文"
)
val values by remember { mutableStateOf(supportedLanguages.map { it.key to it.value }) }
val values by remember(ChatController.appPrefs.appLanguage.state.value) { mutableStateOf(supportedLanguages.map { it.key to it.value }) }
ExposedDropDownSettingRow(
generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
values,
@@ -227,7 +228,9 @@ object AppearanceScope {
@Composable
private fun ThemeSelector(state: State<String>, onSelected: (String) -> Unit) {
val darkTheme = chat.simplex.common.ui.theme.isSystemInDarkTheme()
val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third }) }
val values by remember(ChatController.appPrefs.appLanguage.state.value) {
mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third })
}
ExposedDropDownSettingRow(
generalGetString(MR.strings.theme),
values,

View File

@@ -66,6 +66,24 @@ fun PrivacySettingsView(
SectionView(stringResource(MR.strings.settings_section_title_chats)) {
SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
SettingsPreferenceItem(
painterResource(MR.images.ic_chat_bubble),
stringResource(MR.strings.privacy_show_last_messages),
chatModel.controller.appPrefs.privacyShowChatPreviews,
onChange = { showPreviews ->
chatModel.showChatPreviews.value = showPreviews
}
)
SettingsPreferenceItem(
painterResource(MR.images.ic_edit_note),
stringResource(MR.strings.privacy_message_draft),
chatModel.controller.appPrefs.privacySaveLastDraft,
onChange = { saveDraft ->
if (!saveDraft) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
})
SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
simplexLinkMode.set(it)
chatModel.simplexLinkMode.value = it

View File

@@ -147,7 +147,7 @@
<string name="rcv_conn_event_switch_queue_phase_completed">تم تغيير العنوان من أجلك</string>
<string name="cant_delete_user_profile">لا يمكن حذف ملف تعريف المستخدم!</string>
<string name="icon_descr_video_asked_to_receive">طلب لاستلام الفيديو</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b> إضافة جهة اتصال جديدة </b>: لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.</string>
<string name="add_new_contact_to_create_one_time_QR_code"><![CDATA[<b> إضافة جهة اتصال جديدة </b>: لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.]]></string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b> امسح رمز الاستجابة السريعة </b>: للاتصال بجهة الاتصال التي تعرض لك رمز الاستجابة السريعة.]]></string>
<string name="callstatus_in_progress">مكالمتك تحت الإجراء</string>
<string name="callstatus_ended">انتهت المكالمة</string>
@@ -414,7 +414,7 @@
<string name="settings_developer_tools">أدوات المطور</string>
<string name="smp_server_test_delete_queue">حذف قائمة الانتظار</string>
<string name="error_updating_user_privacy">خطأ في تحديث خصوصية المستخدم</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 سطح المكتب: امسح رمز الاستجابة السريعة (QR) المعروض من التطبيق، عبر <b>مسح رمز QR</b>.</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code"><![CDATA[💻 سطح المكتب: امسح رمز الاستجابة السريعة (QR) المعروض من التطبيق، عبر <b>مسح رمز QR</b>.]]></string>
<string name="delete_profile">حذف ملف التعريف</string>
<string name="smp_servers_delete_server">حذف الخادم</string>
<string name="error_updating_link_for_group">خطأ في تحديث ارتباط المجموعة</string>
@@ -498,7 +498,7 @@
<string name="custom_time_unit_hours">ساعات</string>
<string name="edit_history">السجل</string>
<string name="image_will_be_received_when_contact_completes_uploading">سيتم استلام الصورة عند اكتمال تحميل جهة اتصالك.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">إذا لم تتمكن من الالتقاء شخصيًا، <b>اعرض رمز الاستجابة السريعة في مكالمة الفيديو</b>، أو شارك الرابط.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel"><![CDATA[إذا لم تتمكن من الالتقاء شخصيًا، <b>اعرض رمز الاستجابة السريعة في مكالمة الفيديو</b>، أو شارك الرابط.]]></string>
<string name="install_simplex_chat_for_terminal">ثبّت SimpleX Chat لطرفية</string>
<string name="network_disable_socks_info">إذا قمت بالتأكيد، فستتمكن خوادم المراسلة من رؤية عنوان IP الخاص بك ومزود الخدمة الخاص بك - أي الخوادم التي تتصل بها.</string>
<string name="hide_dev_options">إخفاء:</string>
@@ -521,7 +521,7 @@
<string name="info_menu">المعلومات</string>
<string name="v4_3_improved_privacy_and_security_desc">إخفاء شاشة التطبيق في التطبيقات الحديثة.</string>
<string name="v4_3_improved_privacy_and_security">تحسن الخصوصية والأمان</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">إذا لم تتمكن من الالتقاء شخصيًا، فيمكنك <b>مسح رمز QR في مكالمة الفيديو</b>، أو يمكن لجهة الاتصال مشاركة رابط الدعوة.</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[إذا لم تتمكن من الالتقاء شخصيًا، فيمكنك <b>مسح رمز QR في مكالمة الفيديو</b>، أو يمكن لجهة الاتصال مشاركة رابط الدعوة.]]></string>
<string name="immune_to_spam_and_abuse">محصن ضد البريد العشوائي وسوء المعاملة</string>
<string name="description_via_one_time_link_incognito">التخفي عبر رابط لمرة واحدة</string>
<string name="icon_descr_image_snd_complete">أرسلت صورة</string>
@@ -541,7 +541,7 @@
<string name="onboarding_notifications_mode_service">فوري</string>
<string name="host_verb">المضيف</string>
<string name="hide_notification">إخفاء</string>
<string name="turn_off_battery_optimization">من أجل استخدامها، يرجى <b>تعطيل تحسين البطارية</b> لSimpleX في مربع الحوار التالي. وإلا، سيتم تعطيل الإخطارات.</string>
<string name="turn_off_battery_optimization"><![CDATA[من أجل استخدامها، يرجى <b>تعطيل تحسين البطارية</b> لSimpleX في مربع الحوار التالي. وإلا، سيتم تعطيل الإخطارات.]]></string>
<string name="in_reply_to">ردًا على</string>
<string name="icon_descr_instant_notifications">إشعارات فورية</string>
<string name="enter_one_ICE_server_per_line">خوادم ICE (واحد لكل سطر)</string>
@@ -716,7 +716,7 @@
<string name="ensure_ICE_server_address_are_correct_format_and_unique">تأكد من أن عناوين خادم WebRTC ICE بالتنسيق الصحيح، وأن تكون مفصولة بأسطر وليست مكررة.</string>
<string name="mark_code_verified">وضع علامة تَحقق منه</string>
<string name="error_saving_user_password">خطأ في حفظ كلمة مرور المستخدم</string>
<string name="many_people_asked_how_can_it_deliver">سأل الكثير من الناس: <i>إذا SimpleX ليس لديه معرّفات مستخدم، كيف يمكنه توصيل الرسائل؟</i></string>
<string name="many_people_asked_how_can_it_deliver"><![CDATA[سأل الكثير من الناس: <i>إذا SimpleX ليس لديه معرّفات مستخدم، كيف يمكنه توصيل الرسائل؟</i>]]></string>
<string name="error_saving_group_profile">خطأ في حفظ ملف تعريف المجموعة</string>
<string name="notification_preview_mode_message">رسالة نصية</string>
<string name="message_reactions">ردود فعل الرسائل</string>
@@ -725,7 +725,7 @@
<string name="message_delivery_error_title">خطأ في تسليم الرسالة</string>
<string name="network_and_servers">الشبكة والخوادم</string>
<string name="moderate_verb">إشراف</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 للجوال: انقر فوق <b>فتح في تطبيق الجوال</b> ، ثم انقر فوق <b>اتصال</b> في التطبيق.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app"><![CDATA[📱 للجوال: انقر فوق <b>فتح في تطبيق الجوال</b> ، ثم انقر فوق <b>اتصال</b> في التطبيق.]]></string>
<string name="share_text_moderated_at">تحت الإشراف في: %s</string>
<string name="message_reactions_prohibited_in_this_chat">ردود الفعل الرسائل ممنوعة في هذه الدردشة.</string>
<string name="moderated_item_description">مُشرف بواسطة %s</string>
@@ -839,7 +839,7 @@
<string name="call_connection_peer_to_peer">ند لند</string>
<string name="people_can_connect_only_via_links_you_share">يمكن للناس التواصل معك فقط عبر الرابط الذي تقوم بمشاركته</string>
<string name="icon_descr_call_pending_sent">مكالمة في الانتظار</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل المرسلة باستخدام <b>تشفير ثنائي الطبقات من بين الطريفين</b>.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages"><![CDATA[تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل المرسلة باستخدام <b>تشفير ثنائي الطبقات من بين الطريفين</b>.]]></string>
<string name="reset_color">إعادة تعيين الألوان</string>
<string name="save_verb">حفظ</string>
<string name="smp_servers_preset_address">عنوان الخادم المحدد مسبقًا</string>
@@ -853,7 +853,7 @@
<string name="onboarding_notifications_mode_title">الإشعارات خاصة</string>
<string name="store_passphrase_securely_without_recover">يرجى تخزين عبارة المرور بشكل آمن، فلن تتمكن من الوصول إلى الدردشة إذا فقدتها.</string>
<string name="contact_developers">يرجى تحديث التطبيق والاتصال بالمطورين.</string>
<string name="read_more_in_user_guide_with_link">اقرأ المزيد في &lt;font color=#0088ff&gt;دليل المستخدم&lt;/font&gt;.</string>
<string name="read_more_in_user_guide_with_link"><![CDATA[اقرأ المزيد في <font color="#0088ff">دليل المستخدم</font>.]]></string>
<string name="auth_open_chat_profiles">افتح ملفات تعريف الدردشة</string>
<string name="revoke_file__confirm">اسحب الوصول</string>
<string name="save_archive">حفظ الأرشيف</string>
@@ -878,7 +878,7 @@
<string name="saved_ICE_servers_will_be_removed">ستتم إزالة خوادم WebRTC ICE المحفوظة.</string>
<string name="prohibit_sending_files">منع إرسال الملفات والوسائط.</string>
<string name="callstate_received_answer">استلمت إجابة…</string>
<string name="read_more_in_github_with_link">اقرأ المزيد في &lt;font color=#0088ff&gt;مستودع GitHub&lt;/font&gt;.</string>
<string name="read_more_in_github_with_link"><![CDATA[اقرأ المزيد في <font color="#0088ff">مستودع GitHub</font>.]]></string>
<string name="reject">رفض</string>
<string name="relay_server_protects_ip">يحمي خادم الترحيل عنوان IP الخاص بك، ولكن يمكنه مراقبة مدة المكالمة.</string>
<string name="restore_database_alert_desc">الرجاء إدخال كلمة المرور السابقة بعد استعادة نسخة احتياطية لقاعدة البيانات. لا يمكن التراجع عن هذا الإجراء.</string>
@@ -1090,7 +1090,7 @@
<string name="stop_file__action">إيقاف الملف</string>
<string name="stop_snd_file__title">التوقف عن استلام الملف؟</string>
<string name="icon_descr_address">عنوان SimpleX</string>
<string name="disable_onion_hosts_when_not_supported">اضبط <i>استخدم مضيفي .onion</i> إلى \"لا\" إذا كان وكيل SOCKS لا يدعمها.</string>
<string name="disable_onion_hosts_when_not_supported"><![CDATA[اضبط <i>استخدم مضيفي .onion</i> إلى \"لا\" إذا كان وكيل SOCKS لا يدعمها.]]></string>
<string name="share_with_contacts">مشاركة مع جهات الاتصال</string>
<string name="shutdown_alert_question">إيقاف التشغيل؟</string>
<string name="network_socks_proxy_settings">إعدادات وكيل SOCKS</string>
@@ -1117,7 +1117,7 @@
<string name="v4_4_french_interface_descr">بفضل المستخدمين - المساهمة عبر Weblate!</string>
<string name="v4_6_audio_video_calls_descr">دعم البلوتوث وتحسينات أخرى.</string>
<string name="v5_0_polish_interface_descr">بفضل المستخدمين - المساهمة عبر Weblate!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">للحفاظ على خصوصيتك، بدلاً من دفع الإشعارات، يحتوي التطبيق على <b>خدمة SimpleX تعمل في الخلفية</b> يستخدم نسبة قليلة من البطارية يوميًا.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[للحفاظ على خصوصيتك، بدلاً من دفع الإشعارات، يحتوي التطبيق على <b>خدمة SimpleX تعمل في الخلفية</b> يستخدم نسبة قليلة من البطارية يوميًا.]]></string>
<string name="tap_to_start_new_chat">انقر لبدء محادثة جديدة</string>
<string name="to_share_with_your_contact">(للمشاركة مع جهة اتصالك)</string>
<string name="to_connect_via_link_title">للتواصل عبر الرابط</string>
@@ -1240,7 +1240,7 @@
<string name="snd_group_event_user_left">غادرت</string>
<string name="you_must_use_the_most_recent_version_of_database">يجب عليك استخدام أحدث إصدار من قاعدة بيانات الدردشة الخاصة بك على جهاز واحد فقط، وإلا فقد تتوقف عن تلقي الرسائل من بعض جهات الاتصال.</string>
<string name="video_will_be_received_when_contact_is_online">سيتم استلام الفيديو عندما تكون جهة اتصالك متصلة بالإنترنت، يرجى الانتظار أو التحقق لاحقًا!</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">يمكنك التحكم من خلال الخادم (الخوادم) <b>لتلقي</b> الرسائل وجهات اتصالك - الخوادم التي تستخدمها لمراسلتهم.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send"><![CDATA[يمكنك التحكم من خلال الخادم (الخوادم) <b>لتلقي</b> الرسائل وجهات اتصالك - الخوادم التي تستخدمها لمراسلتهم.]]></string>
<string name="you_can_share_this_address_with_your_contacts">يمكنك مشاركة هذا العنوان مع جهات اتصالك للسماح لهم بالاتصال بـ%s.</string>
<string name="snd_group_event_member_deleted">أُزيلت %1$s</string>
<string name="update_database">تحديث</string>
@@ -1293,7 +1293,7 @@
<string name="unhide_profile">إلغاء إخفاء ملف تعريف</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">يجب أن تكون جهة الاتصال متصلة بالإنترنت حتى يكتمل الاتصال.
\nيمكنك إلغاء هذا الاتصال وإزالة جهة الاتصال (والمحاولة لاحقًا باستخدام رابط جديد).</string>
<string name="you_can_also_connect_by_clicking_the_link">يمكنك أيضًا الاتصال بالضغط على الرابط. إذا تم فتحه في المتصفح، فانقر فوق الزر <b>فتح في تطبيق الجوال</b>.</string>
<string name="you_can_also_connect_by_clicking_the_link"><![CDATA[يمكنك أيضًا الاتصال بالضغط على الرابط. إذا تم فتحه في المتصفح، فانقر فوق الزر <b>فتح في تطبيق الجوال</b>.]]></string>
<string name="smp_servers_use_server_for_new_conn">استخدم للاتصالات الجديدة</string>
<string name="smp_servers_use_server">استخدم الخادم</string>
<string name="smp_servers_your_server_address">عنوان خادمك</string>
@@ -1335,7 +1335,7 @@
<string name="observer_cant_send_message_title">لا يمكنك إرسال رسائل!</string>
<string name="you_need_to_allow_to_send_voice">تحتاج إلى السماح لجهة الاتصال الخاصة بك بإرسال رسائل صوتية لتتمكن من إرسالها.</string>
<string name="contact_sent_large_file">أرسلت جهة اتصالك ملفًا أكبر من الحجم الأقصى المعتمد حاليًا (%1$s).</string>
<string name="you_can_connect_to_simplex_chat_founder">يمكنك &lt;font color=#0088ff&gt;الاتصال بمطوري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات&lt;/font&gt;.</string>
<string name="you_can_connect_to_simplex_chat_founder"><![CDATA[يمكنك <font color=#0088ff>الاتصال بمطوري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات</font>.]]></string>
<string name="smp_servers_your_server">خادمك</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">يُخزن ملف تعريفك على جهازك ومشاركته فقط مع جهات اتصالك. لا تستطيع خوادم SimpleX رؤية ملف تعريفك.</string>
<string name="icon_descr_video_off">الفيديو مقفل</string>

View File

@@ -140,11 +140,11 @@
<string name="turn_off_battery_optimization_button">Allow</string>
<string name="turn_off_system_restriction_button">Open app settings</string>
<string name="disable_notifications_button">Disable notifications</string>
<string name="system_restricted_background_desc"><![CDATA[SimpleX can\'t run in background. You will receive the notifications only when the app is running.]]></string>
<string name="system_restricted_background_desc">SimpleX can\'t run in background. You will receive the notifications only when the app is running.</string>
<string name="system_restricted_background_warn"><![CDATA[To enable notifications, please choose <b>App battery usage</b> / <b>Unrestricted</b> in the app settings.]]></string>
<string name="system_restricted_background_in_call_title">No background calls</string>
<string name="system_restricted_background_in_call_desc"><![CDATA[The app may be closed after 1 minute in background.]]></string>
<string name="system_restricted_background_in_call_warn"><![CDATA[To make calls in background, please choose <b>App battery usage</b> / <b>Unrestricted</b> in the app settings]]>.</string>
<string name="system_restricted_background_in_call_desc">The app may be closed after 1 minute in background.</string>
<string name="system_restricted_background_in_call_warn"><![CDATA[To make calls in background, please choose <b>App battery usage</b> / <b>Unrestricted</b> in the app settings.]]></string>
<string name="enter_passphrase_notification_title">Passphrase is needed</string>
<string name="enter_passphrase_notification_desc">To receive notifications, please, enter the database passphrase</string>
<string name="database_initialization_error_title">Can\'t initialize the database</string>
@@ -852,6 +852,8 @@
<string name="protect_app_screen">Protect app screen</string>
<string name="auto_accept_images">Auto-accept images</string>
<string name="send_link_previews">Send link previews</string>
<string name="privacy_show_last_messages">Show last messages</string>
<string name="privacy_message_draft">Message draft</string>
<string name="full_backup">App data backup</string>
<string name="enable_lock">Enable lock</string>
<string name="lock_mode">Lock mode</string>
@@ -1103,6 +1105,10 @@
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<string name="rcv_group_event_2_members_connected">%s and %s connected</string>
<string name="rcv_group_event_3_members_connected">%s, %s and %s connected</string>
<string name="rcv_group_event_n_members_connected">%s, %s and %d other members connected</string>
<!-- Conn event chat items -->
<string name="rcv_conn_event_switch_queue_phase_completed">changed address for you</string>
<string name="rcv_conn_event_switch_queue_phase_changing">changing address…</string>
@@ -1556,4 +1562,4 @@
<!-- Under development -->
<string name="in_developing_title">Coming soon!</string>
<string name="in_developing_desc">This feature is not yet supported. Try the next release.</string>
</resources>
</resources>

View File

@@ -48,7 +48,7 @@
<string name="accept_feature">Приеми</string>
<string name="color_primary_variant">Допълнителен акцент</string>
<string name="v4_2_group_links_desc">Админите могат да създадат линкове за присъединяване към групи.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Ако не можете да се срещнете лично, <b>покажете QR кода във видеоразговора</b> или споделете линка.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel"><![CDATA[Ако не можете да се срещнете лично, <b>покажете QR кода във видеоразговора</b> или споделете линка.]]></string>
<string name="open_simplex_chat_to_accept_call">Отворете SimpleX Chat, за да приемете повикването</string>
<string name="v4_6_audio_video_calls_descr">Поддръжка на bluetooth и други подобрения.</string>
<string name="relay_server_protects_ip">Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора.</string>
@@ -87,9 +87,9 @@
<string name="incognito_random_profile_from_contact_description">Произволен профил ще бъде изпратен до контакта, от който сте получили тази връзка</string>
<string name="la_authenticate">Идентифицирай</string>
<string name="turning_off_service_and_periodic">Оптимизацията на батерията е активна, изключват се фоновата услуга и периодичните заявки за нови съобщения. Можете да ги активирате отново през настройките.</string>
<string name="network_session_mode_user_description">Ще се използва отделна TCP връзка (и идентификационни данни за SOCKS) <b>за всеки чат профил, който имате в приложението</b>.</string>
<string name="network_session_mode_user_description"><![CDATA[Ще се използва отделна TCP връзка (и идентификационни данни за SOCKS) <b>за всеки чат профил, който имате в приложението</b>.]]></string>
<string name="icon_descr_audio_call">аудио разговор</string>
<string name="onboarding_notifications_mode_off_desc"><b>Най-добро за батерията</b>. Ще получавате известия само когато приложението работи (БЕЗ фонова услуга).</string>
<string name="onboarding_notifications_mode_off_desc"><![CDATA[<b>Най-добро за батерията</b>. Ще получавате известия само когато приложението работи (БЕЗ фонова услуга).]]></string>
<string name="network_session_mode_entity_description">Ще се използва отделна TCP връзка (и идентификационни данни за SOCKS) <b>за всеки контакт и член на група</b>.
\n<b>Моля, обърнете внимание</b>: ако имате много връзки, консумацията на батерията и трафика може да бъде значително по-висока и някои връзки може да се провалят.</string>
<string name="icon_descr_asked_to_receive">Помолен да получи изображението</string>
@@ -99,15 +99,15 @@
<string name="both_you_and_your_contacts_can_delete">И вие, и вашият контакт можете да изтриете необратимо изпратените съобщения.</string>
<string name="auth_unavailable">Идентификацията е недостъпна</string>
<string name="both_you_and_your_contact_can_add_message_reactions">И вие, и вашият контакт можете да добавяте реакции към съобщението.</string>
<string name="impossible_to_recover_passphrase"><b>Моля, обърнете внимание</b>: НЯМА да можете да възстановите или промените паролата, ако я загубите.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Използва повече батерия</b>! Услугата на заден план винаги работи известията се показват веднага щом съобщенията са налични.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Моля, обърнете внимание</b>: НЯМА да можете да възстановите или промените паролата, ако я загубите.]]></string>
<string name="onboarding_notifications_mode_service_desc"><![CDATA[<b>Използва повече батерия</b>! Услугата на заден план винаги работи известията се показват веднага щом съобщенията са налични.]]></string>
<string name="integrity_msg_bad_hash">лош хеш на съобщението</string>
<string name="alert_title_msg_bad_hash">Лош хеш на съобщението</string>
<string name="no_call_on_lock_screen">Деактивиране</string>
<string name="callstatus_calling">повикване…</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Ако не можете да се срещнете лично, можете <b>да сканирате QR код във видеоразговора</b> или вашият контакт може да сподели линк за покана.</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[Ако не можете да се срещнете лично, можете <b>да сканирате QR код във видеоразговора</b> или вашият контакт може да сподели линк за покана.]]></string>
<string name="notifications_mode_service_desc">Услугата във фонов режим винаги работи известията ще се показват веднага щом съобщенията са налични.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Може да бъде деактивирано през настройките</b> известията ще продължат да се показват, докато приложението работи.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><![CDATA[<b>Може да бъде деактивирано през настройките</b> известията ще продължат да се показват, докато приложението работи.]]></string>
<string name="ntf_channel_calls">SimpleX Chat разговори</string>
<string name="notifications_mode_periodic">Започва периодично</string>
<string name="database_initialization_error_title">Базата данни не може да се стартира</string>
@@ -120,8 +120,8 @@
<string name="back">Назад</string>
<string name="cancel_verb">Отказ</string>
<string name="icon_descr_cancel_live_message">Спри живото съобщение</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Добави нов контакт</b>: за да създадете своя еднократен QR код за вашия контакт.</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Сканирай QR код</b>: за да се свържете с вашия контакт, който ви показва QR код.</string>
<string name="add_new_contact_to_create_one_time_QR_code"><![CDATA[<b>Добави нов контакт</b>: за да създадете своя еднократен QR код за вашия контакт.]]></string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b>Сканирай QR код</b>: за да се свържете с вашия контакт, който ви показва QR код.]]></string>
<string name="use_camera_button">Камера</string>
<string name="if_you_cant_meet_in_person">Ако не можете да се срещнете лично, покажете QR код във видеоразговора или споделете линка.</string>
<string name="icon_descr_cancel_link_preview">спри визуализацията на линка</string>
@@ -140,7 +140,7 @@
<string name="callstatus_missed">пропуснато повикване</string>
<string name="callstatus_rejected">отхвърлено повикване</string>
<string name="callstate_starting">стартиране…</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Добър за батерията</b>. Фоновата услуга проверява съобщенията на всеки 10 минути. Може да пропуснете обаждания или спешни съобщения.</string>
<string name="onboarding_notifications_mode_periodic_desc"><![CDATA[<b>Добър за батерията</b>. Фоновата услуга проверява съобщенията на всеки 10 минути. Може да пропуснете обаждания или спешни съобщения.]]></string>
<string name="callstate_connected">свързан</string>
<string name="callstate_connecting">свързване…</string>
<string name="encrypted_audio_call">e2e криптиран аудио разговор</string>
@@ -441,7 +441,7 @@
<string name="custom_time_unit_days">дни</string>
<string name="choose_file_title">Избери файл</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Ако сте получили линк за покана за SimpleX Chat, можете да го отворите във вашия браузър:</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 настолен компютър: сканирайте показания QR код от приложението чрез <b>Сканирай QR код</b>.</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code"><![CDATA[💻 настолен компютър: сканирайте показания QR код от приложението чрез <b>Сканирай QR код</b>.]]></string>
<string name="delete_pending_connection__question">Изтрий предстоящата връзка\?</string>
<string name="icon_descr_email">Електронна поща</string>
<string name="share_invitation_link">Сподели еднократен линк</string>
@@ -452,7 +452,7 @@
<string name="smp_servers_delete_server">Изтрий сървър</string>
<string name="dont_create_address">Не създавай адрес</string>
<string name="display_name__field">Показвано име:</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения, изпратени с <b>двуслойно криптиране от край до край</b>.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages"><![CDATA[Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения, изпратени с <b>двуслойно криптиране от край до край</b>.]]></string>
<string name="receipts_contacts_title_disable">Деактивирай потвърждениeто\?</string>
<string name="receipts_contacts_enable_keep_overrides">Активиране (запазване на промените)</string>
<string name="receipts_contacts_title_enable">Активирай потвърждениeто\?</string>
@@ -766,7 +766,7 @@
<string name="invalid_message_format">невалиден формат на съобщението</string>
<string name="invalid_connection_link">Невалиден линк за връзка</string>
<string name="notification_display_mode_hidden_desc">Скриване на контакт и съобщение</string>
<string name="turn_off_battery_optimization">За да го използвате, моля, <b>деактивирайте оптимизирането на батерията</b> за SimpleX в следващия диалогов прозорец. В противен случай известията ще бъдат деактивирани.</string>
<string name="turn_off_battery_optimization"><![CDATA[За да го използвате, моля, <b>деактивирайте оптимизирането на батерията</b> за SimpleX в следващия диалогов прозорец. В противен случай известията ще бъдат деактивирани.]]></string>
<string name="icon_descr_instant_notifications">Незабавни известия</string>
<string name="service_notifications">Незабавни известия!</string>
<string name="service_notifications_disabled">Незабавните известия са деактивирани!</string>
@@ -791,14 +791,14 @@
<string name="custom_time_unit_seconds">секунди</string>
<string name="custom_time_unit_weeks">седмици</string>
<string name="v5_1_japanese_portuguese_interface">Японски и португалски потребителски интерфейс</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 мобилно: докоснете <b>Отваряне в мобилно приложение</b>, след което докоснете <b>Свързване</b> в приложението.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app"><![CDATA[📱 мобилно: докоснете <b>Отваряне в мобилно приложение</b>, след което докоснете <b>Свързване</b> в приложението.]]></string>
<string name="reject_contact_button">Отхвърляне</string>
<string name="mark_unread">Маркирай като непрочетено</string>
<string name="mark_read">Маркирай като прочетено</string>
<string name="mute_chat">Без звук</string>
<string name="image_descr_qr_code">QR код</string>
<string name="icon_descr_more_button">Повече</string>
<string name="read_more_in_user_guide_with_link">Прочетете повече в &lt;font color=#0088ff&gt;Ръководство за потребителя&lt;/font&gt;.</string>
<string name="read_more_in_user_guide_with_link"><![CDATA[Прочетете повече в <font color=#0088ff>Ръководство за потребителя</font>.]]></string>
<string name="mark_code_verified">Маркирай като проверено</string>
<string name="is_not_verified">%s не е потвърдено</string>
<string name="is_verified">%s е потвърдено</string>
@@ -818,7 +818,7 @@
<string name="email_invite_subject">Нека да поговорим в SimpleX Chat</string>
<string name="password_to_show">Парола за показване</string>
<string name="no_spaces">Без интервали!</string>
<string name="read_more_in_github_with_link">Прочетете повече в нашето &lt;font color=#0088ff&gt;GitHub хранилище&lt;/font&gt;.</string>
<string name="read_more_in_github_with_link"><![CDATA[Прочетете повече в нашето <font color=#0088ff>GitHub хранилище</font>.]]></string>
<string name="onboarding_notifications_mode_off">Когато приложението работи</string>
<string name="onboarding_notifications_mode_periodic">Периодично</string>
<string name="paste_the_link_you_received">Постави получения линк</string>
@@ -973,7 +973,7 @@
<string name="simplex_service_notification_text">Получаване на съобщения…</string>
<string name="notifications_mode_off">Работи, когато приложението е отворено</string>
<string name="simplex_service_notification_title">Simplex Chat услуга</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">За да запази вашата поверителност, вместо да изпозлва push известия, приложението има <b> SimpleX фонова услуга </b> използва няколко процента от батерията на ден.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[За да запази вашата поверителност, вместо да изпозлва push известия, приложението има <b> SimpleX фонова услуга </b> използва няколко процента от батерията на ден.]]></string>
<string name="enter_passphrase_notification_desc">За да получавате известия, моля, въведете паролата на базата данни</string>
<string name="auth_log_in_using_credential">Влезте с вашите идентификационни данни</string>
<string name="message_delivery_error_title">Грешка при доставката на съобщението</string>
@@ -1001,7 +1001,7 @@
<string name="privacy_redefined">Поверителността преосмислена</string>
<string name="read_more_in_github">Прочетете повече в нашето хранилище в GitHub.</string>
<string name="make_private_connection">Добави поверителна връзка</string>
<string name="many_people_asked_how_can_it_deliver">Много хора попитаха: <i>ако SimpleX няма потребителски идентификатори, как може да доставя съобщения\?</i></string>
<string name="many_people_asked_how_can_it_deliver"><![CDATA[Много хора попитаха: <i>ако SimpleX няма потребителски идентификатори, как може да доставя съобщения\?</i>]]></string>
<string name="open_verb">Отвори</string>
<string name="relay_server_if_necessary">Реле сървър се използва само ако е необходимо. Друга страна може да наблюдава вашия IP адрес.</string>
<string name="lock_after">Заключване след</string>
@@ -1157,7 +1157,7 @@
<string name="stop_file__action">Спри файл</string>
<string name="icon_descr_settings">Настройки</string>
<string name="star_on_github">Звезда в GitHub</string>
<string name="disable_onion_hosts_when_not_supported">Задайте <i>Използване на .onion хостове</i> на Не, ако SOCKS проксито не ги поддържа.</string>
<string name="disable_onion_hosts_when_not_supported"><![CDATA[Задайте <i>Използване на .onion хостове</i> на Не, ако SOCKS проксито не ги поддържа.]]></string>
<string name="show_dev_options">Покажи:</string>
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
<string name="save_and_notify_contact">Запази и уведоми контакта</string>
@@ -1257,7 +1257,7 @@
\nМожете да го промените в Настройки.</string>
<string name="your_profile_is_stored_on_your_device">Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство.</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Можете да използвате markdown за форматиране на съобщенията:</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Вие контролирате през кой сървър(и) <b>да получавате</b> съобщенията, вашите контакти сървърите, които използвате, за да им изпращате съобщения.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send"><![CDATA[Вие контролирате през кой сървър(и) <b>да получавате</b> съобщенията, вашите контакти сървърите, които използвате, за да им изпращате съобщения.]]></string>
<string name="use_chat">Използвай чата</string>
<string name="update_database">Актуализация</string>
<string name="you_have_to_enter_passphrase_every_time">Трябва да въвеждате парола при всяко стартиране на приложението - тя не се съхранява на устройството.</string>
@@ -1351,9 +1351,9 @@
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Ще трябва да се идентифицирате, когато стартирате или възобновите приложението след 30 секунди във фонов режим.</string>
<string name="you_are_observer">вие сте наблюдател</string>
<string name="gallery_video_button">Видео</string>
<string name="you_can_connect_to_simplex_chat_founder">Можете да &lt;font color=#0088ff&gt;се свържете с разработчиците на SimpleX Chat, за да задавате въпроси и да получавате актуализации&lt;/font&gt;.</string>
<string name="you_can_connect_to_simplex_chat_founder"><![CDATA[Можете да <font color=#0088ff>се свържете с разработчиците на SimpleX Chat, за да задавате въпроси и да получавате актуализации</font>;.]]></string>
<string name="contact_wants_to_connect_with_you">иска да се свърже с вас!</string>
<string name="you_can_also_connect_by_clicking_the_link">Можете също да се свържете, като натиснете върху линка. Ако се отвори в браузъра, натиснете върху бутона <b>Отваряне в мобилно приложение</b>.</string>
<string name="you_can_also_connect_by_clicking_the_link"><![CDATA[Можете също да се свържете, като натиснете върху линка. Ако се отвори в браузъра, натиснете върху бутона <b>Отваряне в мобилно приложение</b>.]]></string>
<string name="xftp_servers">XFTP сървъри</string>
<string name="update_network_session_mode_question">Актуализиране на режима на изолация на транспорта\?</string>
<string name="your_current_profile">Вашият текущ профил</string>

View File

@@ -762,7 +762,7 @@
<string name="enable_automatic_deletion_message">Esta acción no se puede deshacer. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Puede tardar varios minutos.</string>
<string name="messages_section_description">Esta configuración se aplica a los mensajes del perfil actual</string>
<string name="this_string_is_not_a_connection_link">¡Esta cadena no es un enlace de conexión!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Para preservar tu privacidad, en lugar de notificaciones automáticas la aplicación cuenta con un <b>servicio en segundo planoSimpleX</b>, usa un pequeño porcentaje de la batería al día.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[Para preservar tu privacidad, en lugar de notificaciones automáticas la aplicación cuenta con un <b>servicio en segundo planoSimpleX</b>, usa un pequeño porcentaje de la batería al día.]]></string>
<string name="icon_descr_settings">Configuración</string>
<string name="icon_descr_speaker_off">Altavoz desactivado</string>
<string name="add_contact_or_create_group">Inciar chat nuevo</string>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M241.776-244.5 134-136.5q-13.5 13.5-31.25 6.359Q85-137.281 85-156.5V-818q0-22.969 17.266-40.234Q119.531-875.5 142.5-875.5h675q22.969 0 40.234 17.266Q875-840.969 875-818v516q0 22.969-17.266 40.234Q840.469-244.5 817.5-244.5H241.776ZM142.5-302h675v-516h-675v516Zm0 0v-516 516Z"/></svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@@ -763,7 +763,7 @@
<string name="wrong_passphrase">Password del database sbagliata</string>
<string name="wrong_passphrase_title">Password sbagliata!</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Sei stato/a invitato/a al gruppo. Entra per connetterti con i suoi membri.</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Puoi avviare la chat tramite Impostazioni -&gt; Database o riavviando l\'app.</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Puoi avviare la chat tramite Impostazioni / Database o riavviando l\'app.</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Sei entrato/a in questo gruppo. Connessione al membro del gruppo invitante.</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Non riceverai più messaggi da questo gruppo. La cronologia della chat verrà conservata.</string>
<string name="group_member_status_invited">ha invitato</string>

View File

@@ -530,7 +530,7 @@
<string name="v5_0_large_files_support">Vaizdo įrašai ir failai iki 1GB</string>
<string name="search_verb">Ieškoti</string>
<string name="la_mode_off">Išjungta</string>
<string name="network_session_mode_user_description">Atskiras TCP ryšys (ir SOCKS kredencialas) bus naudojamas <b>kiekvienam pokalbių profiliui, kurį turite programoje</b>.</string>
<string name="network_session_mode_user_description"><![CDATA[Atskiras TCP ryšys (ir SOCKS kredencialas) bus naudojamas <b>kiekvienam pokalbių profiliui, kurį turite programoje</b>.]]></string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">Nepavyko iššifruoti %1$d pranešimų.</string>
<string name="button_add_welcome_message">Pridėti sveikinimo pranešimą</string>
<string name="v5_2_more_things">Dar keletas dalykų</string>
@@ -539,7 +539,7 @@
<string name="always_use_relay">Visada naudoti relę</string>
<string name="icon_descr_asked_to_receive">Paprašė leidimo gauti nuotrauką</string>
<string name="send_disappearing_message_5_minutes">5 minučių</string>
<string name="onboarding_notifications_mode_off_desc"><b>Mažiausiai bateriją eikvojantis variantas</b>. Jūs gausite sistemos pranešimus tik kai bus atidaryta programėlė (NEBUS fone veikiančios paslaugos).</string>
<string name="onboarding_notifications_mode_off_desc"><![CDATA[<b>Mažiausiai bateriją eikvojantis variantas</b>. Jūs gausite sistemos pranešimus tik kai bus atidaryta programėlė (NEBUS fone veikiančios paslaugos).]]></string>
<string name="abort_switch_receiving_address">Atšaukti adreso keitimą</string>
<string name="v4_2_auto_accept_contact_requests">Automatiškai priimti susisiekimo užklausas</string>
<string name="v5_1_self_destruct_passcode_descr">Kai jį suvedate visi duomenys yra pašalinami.</string>
@@ -603,19 +603,19 @@
<string name="one_time_link_short">vienkartinė nuoroda</string>
<string name="accept_call_on_lock_screen">Priimti</string>
<string name="auto_accept_contact">Automatiškai priimti</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Geriau baterijai</b>. Fono paslauga tikrina pranešimus kas 10 minučių. Galite praleisti skambučius arba skubius pranešimus.</string>
<string name="onboarding_notifications_mode_periodic_desc"><![CDATA[<b>Geriau baterijai</b>. Fono paslauga tikrina pranešimus kas 10 minučių. Galite praleisti skambučius arba skubius pranešimus.]]></string>
<string name="callstatus_in_progress">vyksta skambutis</string>
<string name="icon_descr_cancel_live_message">Atšaukti tiesioginę žinutę</string>
<string name="alert_title_cant_invite_contacts">Nepavyko pakviesti kontaktų!</string>
<string name="icon_descr_call_progress">Vyksta skambutis</string>
<string name="feature_cancelled_item">atšaukta %s</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Galima išjungti nustatymuose</b> - pranešimai vis tiek bus rodomi kol programėlė veikia.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><![CDATA[<b>Galima išjungti nustatymuose</b> - pranešimai vis tiek bus rodomi kol programėlė veikia.]]></string>
<string name="icon_descr_cancel_link_preview">atšaukti nuorodos peržiūrą</string>
<string name="both_you_and_your_contact_can_add_message_reactions">Jūs ir jūsų kontaktas galite pridėti reakcijas į žinutę.</string>
<string name="call_on_lock_screen">Skambučiai užrakinimo ekrane:</string>
<string name="invite_prohibited">Nepavyko pakviesti kontakto!</string>
<string name="v4_5_transport_isolation_descr">Pagal pokalbių profilį (numatytieji nustatymai) arba pagal ryšį (BETA).</string>
<string name="onboarding_notifications_mode_service_desc"><b>Naudoja daugiau baterijos</b>! Fono paslauga veikia visada - pranešimai rodomi, kai tik atsiranda žinučių.</string>
<string name="onboarding_notifications_mode_service_desc"><![CDATA[<b>Naudoja daugiau baterijos</b>! Fono paslauga veikia visada - pranešimai rodomi, kai tik atsiranda žinučių.]]></string>
<string name="cannot_access_keychain">Negalima pasiekti \"Keystore\", kad išsaugotumėte duomenų bazės slaptažodį</string>
<string name="icon_descr_cancel_file_preview">Atšaukti failo peržiūrą</string>
<string name="icon_descr_cancel_image_preview">Atšaukti vaizdo peržiūrą</string>

View File

@@ -34,7 +34,7 @@
<string name="incognito_random_profile_from_contact_description">โปรไฟล์แบบสุ่มจะถูกส่งไปยังผู้ติดต่อที่คุณได้รับลิงก์นี้จาก</string>
<string name="incognito_random_profile_description">โปรไฟล์แบบสุ่มจะถูกส่งไปยังผู้ติดต่อของคุณ</string>
<string name="network_session_mode_user_description"><![CDATA[การเชื่อมต่อ TCP (และข้อมูลรับรอง SOCKS) แบบแยกต่างหาก จะถูกใช้ <b> สําหรับแต่ละโปรไฟล์แชทที่คุณมีในแอป </b>]]></string>
<string name="network_session_mode_entity_description">การเชื่อมต่อ TCP (และข้อมูลรับรอง SOCKS) แบบแยกต่างหาก จะถูกใช้ <b> สําหรับผู้ติดต่อแต่ละคนและสมาชิกกลุ่มแต่ละคน </b>\n<b>โปรดทราบ </b>: หากคุณมีการเชื่อมต่อจํานวนมากแบตเตอรี่และปริมาณการใช้การจราจรของคุณอาจสูงขึ้นอย่างมากและการเชื่อมต่อบางอย่างอาจล้มเหลว</string>
<string name="network_session_mode_entity_description"><![CDATA[การเชื่อมต่อ TCP (และข้อมูลรับรอง SOCKS) แบบแยกต่างหาก จะถูกใช้ <b> สําหรับผู้ติดต่อแต่ละคนและสมาชิกกลุ่มแต่ละคน </b>\n<b>โปรดทราบ </b>: หากคุณมีการเชื่อมต่อจํานวนมากแบตเตอรี่และปริมาณการใช้การจราจรของคุณอาจสูงขึ้นอย่างมากและการเชื่อมต่อบางอย่างอาจล้มเหลว]]></string>
<string name="attach">แนบ</string>
<string name="v4_6_audio_video_calls">การโทรด้วยเสียงและวิดีโอ</string>
<string name="auto_accept_contact">ยอมรับอัตโนมัติ</string>
@@ -530,7 +530,7 @@
<string name="error_receiving_file">เกิดข้อผิดพลาดในการรับไฟล์</string>
<string name="if_you_enter_passcode_data_removed">หากคุณใส่รหัสผ่านนี้เมื่อเปิดแอป ข้อมูลแอปทั้งหมดจะถูกลบอย่างถาวร!</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">หากคุณเลือกที่จะปฏิเสธ ผู้ส่งจะไม่ได้รับแจ้ง</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[หากคุณไม่สามารถพบกันในชีวิตจริงได้ คุณสามารถสแกนคิวอาร์โค้ดในวิดีโอคอล </b> หรือผู้ติดต่อของคุณสามารถแชร์ลิงก์เชิญได้]]></string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">หากคุณไม่สามารถพบกันในชีวิตจริงได้ คุณสามารถสแกนคิวอาร์โค้ดในวิดีโอคอล หรือผู้ติดต่อของคุณสามารถแชร์ลิงก์เชิญได้</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel"><![CDATA[หากคุณไม่สามารถพบกันในชีวิตจริงได้ <b>ให้แสดงคิวอาร์โค้ดในวิดีโอคอล</b> หรือแชร์ลิงก์]]></string>
<string name="if_you_cant_meet_in_person">หากคุณไม่สามารถพบกันในชีวิตจริงได้ ให้แสดงคิวอาร์โค้ดในวิดีโอคอล หรือแชร์ลิงก์</string>
<string name="network_disable_socks_info">หากคุณยืนยัน เซิร์ฟเวอร์การส่งข้อความจะสามารถเห็นที่อยู่ IP ของคุณและผู้ให้บริการของคุณ - ซึ่งคือเซิร์ฟเวอร์ใดที่คุณกำลังเชื่อมต่ออยู่</string>
@@ -927,7 +927,7 @@
<string name="smp_servers_scan_qr">สแกนคิวอาร์โค้ดของเซิร์ฟเวอร์</string>
<string name="smp_save_servers_question">บันทึกเซิร์ฟเวอร์\?</string>
<string name="saved_ICE_servers_will_be_removed">เซิร์ฟเวอร์ WebRTC ICE ที่บันทึกไว้จะถูกลบออก</string>
<string name="disable_onion_hosts_when_not_supported">ตั้ง <i>ใช้โฮสต์ .onion</i> เป็น ไม่ หากพร็อกซี SOCKS ไม่รองรับ</string>
<string name="disable_onion_hosts_when_not_supported"><![CDATA[ตั้ง <i>ใช้โฮสต์ .onion</i> เป็น ไม่ หากพร็อกซี SOCKS ไม่รองรับ]]></string>
<string name="show_dev_options">แสดง:</string>
<string name="show_developer_options">แสดงตัวเลือกสําหรับนักพัฒนาซอฟต์แวร์</string>
<string name="share_link">แชร์ลิงก์</string>

View File

@@ -73,7 +73,7 @@
<string name="all_group_members_will_remain_connected">Konuşma üyelerinin tümü bağlı kalacaktır.</string>
<string name="allow_verb">İzin ver</string>
<string name="v5_0_app_passcode">Uygulama erişim kodu</string>
<string name="network_session_mode_user_description"><b>Uygulamadaki her konuşma profliniz için</b> ayrı bir TCP bağlantısı (ve SOCKS kimliği) kullanılacaktır.</string>
<string name="network_session_mode_user_description"><![CDATA[<b>Uygulamadaki her konuşma profliniz için</b> ayrı bir TCP bağlantısı (ve SOCKS kimliği) kullanılacaktır.]]></string>
<string name="network_session_mode_entity_description"><b>Konuştuğun kişilerin ve grup üyelerinin tamamı için</b> ayrı bir TCP bağlantısı (ve SOCKS kimliği) kullanılacaktır.
\n<b>Bilgin olsun</b>: Çok sayıda bağlantın varsa pilin ve veri kullanımın önemli ölçüde artabilir ve bazı bağlantılar başarısız olabilir.</string>
<string name="save_and_notify_group_members">Kaydet ve grup üyelerini bilgilendir</string>
@@ -391,7 +391,7 @@
<string name="send_disappearing_message_custom_time">Kişiselleştirilmiş süre</string>
<string name="copied">Panoya kopyalandı</string>
<string name="share_one_time_link">Tek seferlik davet bağlantısı oluştur</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 masaüstü: uygulamadaki <b>Karekodu okut</b> ile karekodu okut</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code"><![CDATA[💻 masaüstü: uygulamadaki <b>Karekodu okut</b> ile karekodu okut]]></string>
<string name="delete_pending_connection__question">Bekleyen bağlantıları sil\?</string>
<string name="delete_contact_menu_action">Sil</string>
<string name="delete_group_menu_action">Sil</string>
@@ -625,8 +625,8 @@
<string name="notification_display_mode_hidden_desc">Konuşulan kişileri ve mesajları gizle</string>
<string name="v4_3_improved_privacy_and_security_desc">Uygulamayı, son kullanılanlar kısmından gizle.</string>
<string name="how_simplex_works">SimpleX nasıl çalışıyor</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Eğer yüz yüze görüşemiyorsanız <b>bir görüntülü aramada karşıdakine karekodunu gösterebilir</b> ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin.</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Eğer yüz yüze görüşemiyorsanız <b>bir görüntülü aramada karşıdakinin karekodunu okutabilirsin</b> ya da konuştuğun kişi seninle bir katılım bağlantısı paylaşabilir.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel"><![CDATA[Eğer yüz yüze görüşemiyorsanız <b>bir görüntülü aramada karşıdakine karekodunu gösterebilir</b> ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin.]]></string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[Eğer yüz yüze görüşemiyorsanız <b>bir görüntülü aramada karşıdakinin karekodunu okutabilirsin</b> ya da konuştuğun kişi seninle bir katılım bağlantısı paylaşabilir.]]></string>
<string name="if_you_cant_meet_in_person">Eğer yüz yüze görüşemiyorsanız bir görüntülü aramada karşıdakine karekodunu gösterebilir ya da konuştuğun kişiye bir katılım bağlantısı paylaşabilirsin.</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Eğer geri çevirmeyi seçersen göndericiye bildirilmeyecek.</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Eğer SimplexX Chat katılım bağlantısı alırsan bu bağlantıyı tarayıcında açabilirsin:</string>
@@ -717,7 +717,7 @@
<string name="icon_descr_instant_notifications">Anlık bildirimler</string>
<string name="service_notifications">Anlık bildirimler</string>
<string name="service_notifications_disabled">Anlık bildirimler devre dışı!</string>
<string name="turn_off_battery_optimization">Bunu kullanmak için lütfen bir sonraki iletişim kutusunda SimpleX için <b>pil optimizasyonunu devre dışı bırakın</b>. Aksi takdirde, bildirimler devre dışı bırakılacaktır.</string>
<string name="turn_off_battery_optimization"><![CDATA[Bunu kullanmak için lütfen bir sonraki iletişim kutusunda SimpleX için <b>pil optimizasyonunu devre dışı bırakın</b>. Aksi takdirde, bildirimler devre dışı bırakılacaktır.]]></string>
<string name="notification_preview_mode_contact">Kişi ismi</string>
<string name="auth_device_authentication_is_disabled_turning_off">Cihaz doğrulaması devre dışı. SimpleX Kilidi Kapatılıyor.</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Cihaz doğrulaması etkin değil. Cihaz doğrulamasını etkinleştirdikten sonra SimpleX Kilidini Ayarlar üzerinden açabilirsiniz.</string>
@@ -740,7 +740,7 @@
<string name="v4_3_irreversible_message_deletion">Geri alınamaz mesaj silme</string>
<string name="v4_5_italian_interface">İtalyanca arayüz</string>
<string name="choose_file_title">Dosya seç</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>QR kodunu tara</b>: size QR kodunu gösteren kişiyle bağlantı kurmak için.</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b>QR kodunu tara</b>: size QR kodunu gösteren kişiyle bağlantı kurmak için.]]></string>
<string name="invite_friends">Arkadaşlarınızı davet edin</string>
<string name="bold_text">kalın</string>
<string name="italic_text">İtalik</string>
@@ -748,7 +748,7 @@
<string name="status_contact_has_e2e_encryption">Kişi uçtan uca şifrelemeye sahiptir</string>
<string name="status_contact_has_no_e2e_encryption">Kişi uçtan uca şifrelemeye sahip değildir</string>
<string name="chat_is_stopped">Sohbet durduruldu</string>
<string name="impossible_to_recover_passphrase"><b>Aklınızda bulunsun</b>: kaybederseniz, parolayı kurtaramaz veya değiştiremezsiniz.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Aklınızda bulunsun</b>: kaybederseniz, parolayı kurtaramaz veya değiştiremezsiniz.]]></string>
<string name="chat_archive_header">Sohbet arşivi</string>
<string name="chat_archive_section">SOHBET ARŞİVİ</string>
<string name="group_invitation_item_description">1$s grubuna davet</string>
@@ -767,7 +767,7 @@
<string name="callstatus_connecting">Aramaya bağlanılıyor…</string>
<string name="delivery_receipts_are_disabled">Gönderildi bilgisi kapalı!</string>
<string name="v5_2_more_things">Birkaç şey daha</string>
<string name="onboarding_notifications_mode_service_desc"><b>Daha fazla pil kullanır</b>! Arka plan hizmeti her zaman çalışır - mesajlar gelir gelmez bildirim gönderilir.</string>
<string name="onboarding_notifications_mode_service_desc"><![CDATA[<b>Daha fazla pil kullanır</b>! Arka plan hizmeti her zaman çalışır - mesajlar gelir gelmez bildirim gönderilir.]]></string>
<string name="in_developing_title">Çok yakında!</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Kişi ve tüm mesajlar silinecektir - bu geri alınamaz!</string>
<string name="alert_title_contact_connection_pending">Kişi henüz bağlanmadı!</string>
@@ -783,7 +783,7 @@
<string name="description_via_contact_address_link_incognito">Bağlantı linki ile gizli</string>
<string name="description_via_one_time_link_incognito">Tek seferlik bağlantı ile gizli</string>
<string name="smp_server_test_compare_file">Dosyaları karşılaştır</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>ayarlardan devre dışı bırakılabilir</b> - uygulama çalışıyorken bildirimler gösterilmeye devam edilecektir.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><![CDATA[<b>ayarlardan devre dışı bırakılabilir</b> - uygulama çalışıyorken bildirimler gösterilmeye devam edilecektir.]]></string>
<string name="turning_off_service_and_periodic">Pil optimizasyonu etkin, arka plan hizmeti kapatılacak ve düzenli olarak yeni mesajlar kontrol edilmeyecek . Bunları ayarlardan yeniden etkinleştirebilirsiniz.</string>
<string name="enter_passphrase_notification_title">Parola gerekli</string>
<string name="notification_preview_mode_message">Mesaj metni</string>
@@ -794,7 +794,7 @@
<string name="delete_message_cannot_be_undone_warning">Mesajlar silinecek - bu geri alınamaz!</string>
<string name="switch_receiving_address_question">Alıcı adresini değiştir\?</string>
<string name="back">Geri</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Yeni kişi ekle</b>: Kişiniz için tek seferlik QR Kodunuzu oluşturmak için.</string>
<string name="add_new_contact_to_create_one_time_QR_code"><![CDATA[<b>Yeni kişi ekle</b>: Kişiniz için tek seferlik QR Kodunuzu oluşturmak için.]]></string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Bağlantı isteğiniz kabul edildiğinde bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Kişinizin cihazı çevrimiçi olduğunda bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin!</string>
<string name="learn_more">Daha fazla bilgi edinin</string>
@@ -836,10 +836,10 @@
<string name="mark_read">Okundu olarak işaretle</string>
<string name="mark_unread">Okunmadı olarak işaretle</string>
<string name="chat_console">Sohbet konsolu</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Pil için iyi</b>. Arka plan hizmeti mesajları 10 dakikada bir kontrol eder. Aramaları veya acil mesajları kaçırabilirsiniz.</string>
<string name="onboarding_notifications_mode_periodic_desc"><![CDATA[<b>Pil için iyi</b>. Arka plan hizmeti mesajları 10 dakikada bir kontrol eder. Aramaları veya acil mesajları kaçırabilirsiniz.]]></string>
<string name="v4_4_verify_connection_security_desc">Güvenlik kodlarını kişilerinizle karşılaştırın.</string>
<string name="notifications_mode_service_desc">Arka plan hizmeti her zaman çalışır - mesajlar gelir gelmez bildirim gönderilir.</string>
<string name="onboarding_notifications_mode_off_desc"><b>Pil için en iyisi</b>. Sadece uygulama çalışırken bildirim alırsınız (arka plan hizmeti YOK).</string>
<string name="onboarding_notifications_mode_off_desc"><![CDATA[<b>Pil için en iyisi</b>. Sadece uygulama çalışırken bildirim alırsınız (arka plan hizmeti YOK).]]></string>
<string name="snd_conn_event_switch_queue_phase_changing_for_member">%s için adres değiştiriliyor.</string>
<string name="large_file">Büyük dosya!</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Grup sahibinin cihazı çevrimiçi olduğunda gruba bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin!</string>
@@ -869,7 +869,7 @@
<string name="your_ICE_servers">ICE sunucularınız</string>
<string name="how_to">Nasıl</string>
<string name="your_current_profile">Mevcut profiliniz</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Mesajların hangi sunucu(lar)dan <b>alınacağını</b> siz kontrol edersiniz, kişileriniz - onlara mesaj göndermek için kullandığınız sunucular.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send"><![CDATA[Mesajların hangi sunucu(lar)dan <b>alınacağını</b> siz kontrol edersiniz, kişileriniz - onlara mesaj göndermek için kullandığınız sunucular.]]></string>
<string name="video_call_no_encryption">video arama (uçtan uca şifreli değil)</string>
<string name="your_ice_servers">ICE sunucularınız</string>
<string name="icon_descr_video_off">Video kapalı</string>
@@ -908,8 +908,8 @@
\nBu bağlantıyı iptal edebilir ve kişiyi kaldırabilirsiniz (ve daha sonra yeni bir bağlantıyla deneyebilirsiniz).</string>
<string name="contact_sent_large_file">Kişiniz desteklenen maksimum boyuttan (%1$s) daha büyük bir dosya gönderdi.</string>
<string name="video_will_be_received_when_contact_completes_uploading">Kişiniz yüklemeyi tamamladığında video alınacaktır.</string>
<string name="you_can_also_connect_by_clicking_the_link">Ayrıca bağlantıya tıklayarak da bağlanabilirsiniz. Eğer bağlantı tarayıcda açılırsa, <b>mobil uygulamada aç</b> seçeneğine tıklayın.</string>
<string name="you_can_connect_to_simplex_chat_founder">Soru sormak ve güncellemeleri almak için &lt;font color=#0088ff&gt;SimpleX Chat geliştiricilerine bağlanabilirsiniz&lt;/font&gt;.</string>
<string name="you_can_also_connect_by_clicking_the_link"><![CDATA[Ayrıca bağlantıya tıklayarak da bağlanabilirsiniz. Eğer bağlantı tarayıcda açılırsa, <b>mobil uygulamada aç</b> seçeneğine tıklayın.]]></string>
<string name="you_can_connect_to_simplex_chat_founder"><![CDATA[Soru sormak ve güncellemeleri almak için <font color=#0088ff>SimpleX Chat geliştiricilerine bağlanabilirsiniz</font>.]]></string>
<string name="you_can_hide_or_mute_user_profile">Bir kullanıcının profilini gizleyebilir veya sessize alabilirsiniz - menü için basılı tutun.</string>
<string name="you_invited_your_contact">Kişinizi davet ettiniz</string>
<string name="you_must_use_the_most_recent_version_of_database">Sohbet veritabanınızın en son sürümünü SADECE bir cihazda kullanmalısınız, aksi takdirde bazı kişilerden daha fazla mesaj alamayabilirsiniz.</string>
@@ -957,7 +957,7 @@
\n- ve daha fazlası!</string>
<string name="item_status_rcv_new_desc">Bu göndericiden yeni bir mesaj var.</string>
<string name="item_status_snd_error_auth_desc">Mesaj teslim hatası. Büyük olasılıkla bu alıcı sizinle olan bağlantısını silmiştir.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Sadece istemci cihazlar <b>2 katmanlı uçtan uca şifreleme</b> ile kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages"><![CDATA[Sadece istemci cihazlar <b>2 katmanlı uçtan uca şifreleme</b> ile kullanıcı profillerini, kişileri, grupları ve gönderilen mesajları depolar.]]></string>
<string name="only_group_owners_can_change_prefs">Grup tercihlerini sadece grup sahipleri değiştirebilir.</string>
<string name="item_info_no_text">metin yok</string>
<string name="network_status">Ağ durumu</string>
@@ -966,7 +966,7 @@
<string name="change_lock_mode">Kilit modunu değiştir</string>
<string name="trying_to_connect_to_server_to_receive_messages">Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor.</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Lütfen doğru bağlantıyı kullandığınızı kontrol edin veya irtibat kişinizden size başka bir bağlantı göndermesini isteyin.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Gizliliğinizi korumak için, anlık bildirimler yerine <b>SimpleX arka plan hizmeti</b> kullanılır - günde pilin yüzde birkaçını kullanır.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[Gizliliğinizi korumak için, anlık bildirimler yerine <b>SimpleX arka plan hizmeti</b> kullanılır - günde pilin yüzde birkaçını kullanır.]]></string>
<string name="periodic_notifications">Periyodik bildirimler</string>
<string name="periodic_notifications_disabled">Periyodik bildirimler devre dışı</string>
<string name="enter_passphrase_notification_desc">Bildirimleri almak için lütfen veri tabanı parolasını girin</string>
@@ -988,7 +988,7 @@
<string name="this_link_is_not_a_valid_connection_link">Bu geçerli bir bağlantı linki değil</string>
<string name="this_QR_code_is_not_a_link">Bu QR kodu bir bağlantı değil!</string>
<string name="paste_connection_link_below_to_connect">Kişinizle bağlantı kurmak için aldığınız bağlantıyı aşağıdaki kutuya yapıştırın.</string>
<string name="read_more_in_user_guide_with_link">Daha fazla bilgi için &lt;font color=#0088ff&gt;Kullanıcı Kılavuzu&lt;/font&gt;.</string>
<string name="read_more_in_user_guide_with_link"><![CDATA[Daha fazla bilgi için <font color=#0088ff>Kullanıcı Kılavuzu</font>.]]></string>
<string name="paste_button">Yapıştır</string>
<string name="this_string_is_not_a_connection_link">Bu dize bir bağlantı linki değil!</string>
<string name="rate_the_app">Uygulamaya puan verin</string>
@@ -1033,7 +1033,7 @@
<string name="unfavorite_chat">Favorilerden çıkar</string>
<string name="make_profile_private">Sohbeti gizli yap!</string>
<string name="profile_update_will_be_sent_to_contacts">Profil güncellemesi kişilerinize gönderilecektir.</string>
<string name="read_more_in_github_with_link">&lt;font color=#0088ff&gt;GitHub repomuzda&lt;/font&gt; daha fazlasını okuyun.</string>
<string name="read_more_in_github_with_link"><![CDATA[<font color=#0088ff>GitHub repomuzda</font> daha fazlasını okuyun.]]></string>
<string name="alert_text_fragment_please_report_to_developers">Lütfen geliştiricilere bildirin.</string>
<string name="users_delete_with_connections">Profil ve sunucu bağlantıları</string>
<string name="user_unhide">gizlemeyi kaldır</string>

View File

@@ -74,7 +74,7 @@
<string name="notifications_mode_off_desc">Додаток може отримувати сповіщення лише під час роботи, жодні фонові служби не запускаються</string>
<string name="chat_preferences_always">завжди</string>
<string name="allow_your_contacts_to_call">Дозвольте вашим контактам телефонувати вам.</string>
<string name="network_session_mode_user_description"><![CDATA[Для кожного профілю чату, який ви маєте в додатку</b>, буде використовуватися окреме TCP-з\'єднання (і SOCKS-обліковий запис) <b>.]]></string>
<string name="network_session_mode_user_description"><![CDATA[<b>Для кожного профілю чату, який ви маєте в додатку</b>, буде використовуватися окреме TCP-з\'єднання (і SOCKS-обліковий запис).]]></string>
<string name="appearance_settings">Зовнішній вигляд</string>
<string name="app_version_name">Версія програми: v%s</string>
<string name="network_session_mode_entity_description"><b>Для кожного контакту і члена групи</b> буде використовуватися окреме TCP-з\'єднання (і SOCKS-обліковий запис).

View File

@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.defaultLocale
import chat.simplex.common.platform.desktopPlatform
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.FileDialogChooser
@@ -25,89 +26,96 @@ import java.io.File
val simplexWindowState = SimplexWindowState()
fun showApp() = application {
// TODO: remove after update to compose 1.5.0+
// See: https://github.com/JetBrains/compose-multiplatform/issues/3366#issuecomment-1643799976
System.setProperty("compose.scrolling.smooth.enabled", "false")
// For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366,
// it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here
val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp
val windowState = rememberWindowState(placement = WindowPlacement.Floating, width = width, height = 768.dp)
simplexWindowState.windowState = windowState
Window(state = windowState, onCloseRequest = ::exitApplication, onKeyEvent = {
if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) {
simplexWindowState.backstack.lastOrNull()?.invoke() != null
} else {
false
}
}, title = "SimpleX") {
SimpleXTheme {
AppScreen()
if (simplexWindowState.openDialog.isAwaiting) {
FileDialogChooser(
title = "SimpleX",
isLoad = true,
params = simplexWindowState.openDialog.params,
onResult = {
simplexWindowState.openDialog.onResult(it.firstOrNull())
}
)
// Reload all strings in all @Composable's after language change at runtime
if (remember { ChatController.appPrefs.appLanguage.state }.value != "") {
Window(state = windowState, onCloseRequest = ::exitApplication, onKeyEvent = {
if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) {
simplexWindowState.backstack.lastOrNull()?.invoke() != null
} else {
false
}
if (simplexWindowState.openMultipleDialog.isAwaiting) {
FileDialogChooser(
title = "SimpleX",
isLoad = true,
params = simplexWindowState.openMultipleDialog.params,
onResult = {
simplexWindowState.openMultipleDialog.onResult(it)
}
)
}
if (simplexWindowState.saveDialog.isAwaiting) {
FileDialogChooser(
title = "SimpleX",
isLoad = false,
params = simplexWindowState.saveDialog.params,
onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) }
)
}
val toasts = remember { simplexWindowState.toasts }
val toast = toasts.firstOrNull()
if (toast != null) {
Box(Modifier.fillMaxSize().padding(bottom = 20.dp), contentAlignment = Alignment.BottomCenter) {
Text(
toast.first,
Modifier.background(MaterialTheme.colors.primary, RoundedCornerShape(100)).padding(vertical = 5.dp, horizontal = 10.dp),
color = MaterialTheme.colors.onPrimary,
style = MaterialTheme.typography.body1
}, title = "SimpleX") {
SimpleXTheme {
AppScreen()
if (simplexWindowState.openDialog.isAwaiting) {
FileDialogChooser(
title = "SimpleX",
isLoad = true,
params = simplexWindowState.openDialog.params,
onResult = {
simplexWindowState.openDialog.onResult(it.firstOrNull())
}
)
}
// Shows toast in insertion order with preferred delay per toast. New one will be shown once previous one expires
LaunchedEffect(toast, toasts.size) {
delay(toast.second)
simplexWindowState.toasts.removeFirst()
}
}
}
var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.performLA.value && delay > 0) {
delay(delay * 1000L)
// Trigger auth state check when delay ends (and if it ends)
AppLock.recheckAuthState()
}
}
LaunchedEffect(Unit) {
window.addWindowFocusListener(object : WindowFocusListener {
override fun windowGainedFocus(p0: WindowEvent?) {
windowFocused = true
AppLock.recheckAuthState()
if (simplexWindowState.openMultipleDialog.isAwaiting) {
FileDialogChooser(
title = "SimpleX",
isLoad = true,
params = simplexWindowState.openMultipleDialog.params,
onResult = {
simplexWindowState.openMultipleDialog.onResult(it)
}
)
}
override fun windowLostFocus(p0: WindowEvent?) {
windowFocused = false
AppLock.appWasHidden()
if (simplexWindowState.saveDialog.isAwaiting) {
FileDialogChooser(
title = "SimpleX",
isLoad = false,
params = simplexWindowState.saveDialog.params,
onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) }
)
}
})
val toasts = remember { simplexWindowState.toasts }
val toast = toasts.firstOrNull()
if (toast != null) {
Box(Modifier.fillMaxSize().padding(bottom = 20.dp), contentAlignment = Alignment.BottomCenter) {
Text(
toast.first,
Modifier.background(MaterialTheme.colors.primary, RoundedCornerShape(100)).padding(vertical = 5.dp, horizontal = 10.dp),
color = MaterialTheme.colors.onPrimary,
style = MaterialTheme.typography.body1
)
}
// Shows toast in insertion order with preferred delay per toast. New one will be shown once previous one expires
LaunchedEffect(toast, toasts.size) {
delay(toast.second)
simplexWindowState.toasts.removeFirst()
}
}
}
var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.performLA.value && delay > 0) {
delay(delay * 1000L)
// Trigger auth state check when delay ends (and if it ends)
AppLock.recheckAuthState()
}
}
LaunchedEffect(Unit) {
window.addWindowFocusListener(object: WindowFocusListener {
override fun windowGainedFocus(p0: WindowEvent?) {
windowFocused = true
AppLock.recheckAuthState()
}
override fun windowLostFocus(p0: WindowEvent?) {
windowFocused = false
AppLock.appWasHidden()
}
})
}
}
}
}

View File

@@ -36,18 +36,15 @@ private val settingsThemesProps =
actual val settings: Settings = PropertiesSettings(settingsProps) { settingsProps.store(settingsFile.writer(), "") }
actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) { settingsThemesProps.store(settingsThemesFile.writer(), "") }
actual fun screenOrientation(): ScreenOrientation = ScreenOrientation.UNDEFINED
@Composable // LALAL
actual fun screenWidth(): Dp {
return java.awt.Toolkit.getDefaultToolkit().screenSize.width.dp.also { println("LALAL $it") }
/*var width by remember { mutableStateOf(java.awt.Toolkit.getDefaultToolkit().screenSize.width.also { println("LALAL $it") }) }
SideEffect {
if (width != java.awt.Toolkit.getDefaultToolkit().screenSize.width)
width = java.awt.Toolkit.getDefaultToolkit().screenSize.width
actual fun windowOrientation(): WindowOrientation =
if (simplexWindowState.windowState.size.width > simplexWindowState.windowState.size.height) {
WindowOrientation.LANDSCAPE
} else {
WindowOrientation.PORTRAIT
}
return width*/
}// LALAL java.awt.Desktop.getDesktop()
@Composable
actual fun windowWidth(): Dp = simplexWindowState.windowState.size.width
actual fun desktopExpandWindowToWidth(width: Dp) {
if (simplexWindowState.windowState.size.width >= width) return

View File

@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
android.version_name=5.3-beta.3
android.version_code=141
android.version_name=5.3-beta.5
android.version_code=147
desktop.version_name=1.1.0
desktop.version_code=3
desktop.version_name=1.3.0
desktop.version_code=5
kotlin.version=1.8.20
gradle.plugin.version=7.4.2

View File

@@ -17,12 +17,14 @@ import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad.Reader
import qualified Data.ByteString.Char8 as B
import Data.Time.Clock (getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone)
import Data.Maybe (fromMaybe, isJust, maybeToList)
import Data.List (sortOn)
import Data.Maybe (fromMaybe, maybeToList)
import Data.Ord (Down(..))
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone)
import Directory.Events
import Directory.Options
import Directory.Store
@@ -31,7 +33,6 @@ import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Messages
-- import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Options
import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Types
@@ -134,12 +135,13 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {
_ -> "Error joining group " <> displayName <> ", please re-send the invitation!"
deContactConnected :: Contact -> IO ()
deContactConnected ct = unless (isJust $ viaGroup ct) $ do
deContactConnected ct = when (contactDirect ct) $ do
unless testing $ putStrLn $ T.unpack (localDisplayName' ct) <> " connected"
sendMessage cc ct $
"Welcome to " <> serviceName <> " service!\n\
\Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\
\For example, send _privacy_ to find groups about privacy."
\For example, send _privacy_ to find groups about privacy.\n\n\
\Content and privacy policy: https://simplex.chat/docs/directory.html"
deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO ()
deGroupInvitation ct g@GroupInfo {groupProfile = GroupProfile {displayName, fullName}} fromMemberRole memberRole = do
@@ -382,7 +384,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {
DCHelp ->
sendMessage cc ct $
"You must be the owner to add the group to the directory:\n\
\1. Invite " <> serviceName <> " bot to your group as *admin*.\n\
\1. Invite " <> serviceName <> " bot to your group as *admin* (you can send `/list` to see all groups you submitted).\n\
\2. " <> serviceName <> " bot will create a public group link for the new members to join even when you are offline.\n\
\3. You will then need to add this link to the group welcome message.\n\
\4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\
@@ -394,7 +396,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {
[] -> sendReply "No groups found"
gs -> do
sendReply $ "Found " <> show (length gs) <> " group(s)" <> if length gs > 10 then ", sending 10." else ""
void . forkIO $ forM_ (take 10 gs) $
void . forkIO $ forM_ (take 10 $ sortOn (Down . currentMembers . snd) gs) $
\(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do
let membersStr = "_" <> tshow currentMembers <> " members_"
text = groupInfoText p <> "\n" <> membersStr

View File

@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 82aec2cd8f7b4033dbf08d5de33ced216f574bbb
tag: cf2a17b80ce5736a8b3b02016e3f466f781f259d
source-repository-package
type: git

101
docs/DIRECTORY.md Normal file
View File

@@ -0,0 +1,101 @@
---
title: SimpleX Directory Service
revision: 18.08.2023
---
# SimpleX Directory Service
You can use an experimental directory service to discover the groups created and registered by other users.
## Searching for groups
Connect to the directory service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups.
Please note that your search queries can be kept by the bot as the conversation history, but you can use incognito mode when connecting to the bot, to avoid correlation with any other communications. See [Privacy policy](../PRIVACY.md) for more details.
## Adding groups to the directory
### How to add a group
To add a group you must be its owner. Once you connect to the directory service and send `/help`, the service will guide you through the process.
1. Invite SimpleX Service Directory to the group as `admin` member. You can also set the role to `admin` after inviting the directory service.
The directory service needs to be `admin` to provide a good user experience of joining the group, as it will create a new link to join the group, which is expected to be online 99% of the time.
2. Add the link sent to you by the directory service to the group welcome message. This has to be done by the same group member who invited the directory service to the group. This member will be the owner of the group record in the directory service.
3. Once the link is added, the group will need to be approved by the directory service admins. This link is functional even before the group is approved, and you can continue using this link even if the group is not approved.
The group is usually approved within 24 hours. Please see below which groups can be added.
Once the group is approved, it will appear in search results.
You can list all the groups you submitted by sending `/list` to the directory service.
### How to remove the group from the directory
Changing the group profile in any way (e.g., changing the group name, welcome message, or removing the link to join the group from the welcome message) will remove the group from the search results until the group is approved again by the directory service admins.
If it is undesirable that the service cannot be found in search during this time, please coordinate the time of this change with the directory service admins for quick approval.
Changing the role of the directory service will temporarily remove the group from the search results, and unless you changed the role to the `owner`, it will also permanently disrupt the members that were in the process of connecting to other members via the directory service.
To remove the group from the directory:
1. Remove the group link created by the directory service from the welcome message. This will not disrupt the members from joining the group, even via this link, but will remove the group from the search results.
2. After some time (we recommend 3-4 days) remove the directory service from the group - it will stop receiving the messages and the group will be permanently removed from the search results.
Removing the group does not prevent you from registering the group again in the future.
### Why limit which groups can be added
The reason to restrict the acceptable content is to have a better experience for a wider range of the users, and to comply with the content policies of app distribution channels (App Store, Play Store, etc.), once the directory service is available via the app without additional configuration. To achieve that, the content in the listed groups should be restricted to be generally appropriate.
Doesn't it go against the idea of decentralization and freedom of speech?
We believe it does not, because:
1. The service only restricts the content in the groups that you choose to register we have no register of all existing groups, and no access to their content.
2. The service itself is open-source, and can be self-hosted, so anybody can run an alternative directory service with the different content policies, or without any policies at all.
3. Freedom of speech should respect legal rights and freedoms of others, so agreeing some boundaries seems necessary.
### Which groups can be added
The below is not the final policy, it is a work in progress.
Currently, the group registration is limited and manual, as we have limited resources to evaluate the content of the groups, so the initial content policy is quite restrictive - we believe it is better to be able to extend what is allowed, than to have to reduce it.
To be "listed in the directory" <sup>\*</sup>, the group must have at least 10 members. Both the group and group owner profiles must include relevant, appropriate, non-offensive avatar images, that do not use the existing trademarks.
Please ONLY submit the groups on the following subjects:
- communications solutions and providers (messengers, social networks, Internet, etc.)
- privacy and security
- cryptocurrencies
- product and software development
- science and technology
- media and entertainment: books, music, movies and games
- politics, society, culture and education
The content in the group must be "appropriate" for the general audience, starting from 12 years old.
The content in the listed groups must:
- be legal for the jurisdiction you are in.
- NOT contain spam and advertising.
- NOT contain violence, calls for violence, calls for public demonstrations, or any other disturbing content.
- NOT contain pornography, nudity, erotic or any sex-related content.
- NOT contain racism, hate speech, or any other content that promotes discrimination.
- NOT contain information about drugs, alcohol, or any other substances.
- NOT contain [NSFW](https://en.wikipedia.org/wiki/Not_safe_for_work) content.
- NOT be offensive this needs to be clarified.
Group owners are expected to moderate the content in the groups, if members post inappropriate or excessive amount of content and group owners do not moderate it, the group is likely to be removed from the directory.
We reserve the right to not accept the group listing in the directory or cancel its listing, and there may be cases when we can't provide an explanation. We will certainly try to avoid it by communicating with the group owners first.
The combination of display name and full name has to be unique for the listed groups.
Once the group is listed in the directory, the bot will invite you to join the group of the group owners, where you can send any ideas or suggestions for how the groups functionality should evolve, and help steer both the product and the policies.
<sup>\*</sup> "listed" means discoverable via search or any other directory service functions by any connected users other than the user who submitted the registration

View File

@@ -1,6 +1,6 @@
---
title: Hosting your own SMP Server
revision: 05.06.2023
revision: 31.07.2023
---
| Updated 05.06.2023 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) |
@@ -19,17 +19,27 @@ _Please note_: when you change the servers in the app configuration, it only aff
0. First, install `smp-server`:
- Manual deployment:
- Manual deployment (see below)
- [Compiling from source](https://github.com/simplex-chat/simplexmq#using-your-distribution)
- [Using pre-compiled binaries](https://github.com/simplex-chat/simplexmq#install-binaries)
- Alternatively, you can deploy `smp-server` using:
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker-1)
- Semi-automatic deployment:
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
- [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode)
Manual installation requires some preliminary actions:
0. Install binary:
- Using offical binaries:
```sh
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server
```
- Compiling from source:
Please refer to [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution)
1. Create user and group for `smp-server`:
```sh
@@ -57,24 +67,104 @@ Manual installation requires some preliminary actions:
```sh
[Unit]
Description=SMP server
Description=SMP server systemd service
[Service]
User=smp
Group=smp
Type=simple
ExecStart=smp-server start
ExecStart=/usr/local/bin/smp-server start +RTS -N -RTS
ExecStopPost=/usr/bin/env sh -c '[ -e "/var/opt/simplex/smp-server-store.log" ] && cp "/var/opt/simplex/smp-server-store.log" "/var/opt/simplex/smp-server-store.log.bak"'
LimitNOFILE=65535
KillSignal=SIGINT
TimeoutStopSec=infinity
Restart=always
RestartSec=10
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
```
And execute `sudo systemctl daemon-reload`.
## Tor installation
smp-server can also be deployed to serve from [tor](https://www.torproject.org) network. Run the following commands as `root` user.
1. Install tor:
We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [offical tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide.
- Configure offical Tor PPA repository:
```sh
CODENAME="$(lsb_release -c | awk '{print $2}')"
echo "deb [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org ${CODENAME} main
deb-src [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org ${CODENAME} main" > /etc/apt/sources.list.d/tor.list
```
- Import repository key:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | tee /usr/share/keyrings/tor-archive-keyring.gpg >/dev/null
```
- Update repository index:
```sh
apt update
```
- Install `tor` package:
```sh
apt install -y tor deb.torproject.org-keyring
```
2. Configure tor:
- File configuration:
Open tor configuration with your editor of choice (`nano`,`vim`,`emacs`,etc.):
```sh
vim /etc/tor/torrc
```
And insert the following lines to the bottom of configuration. Please note lines starting with `#`: this is comments about each individual options.
```sh
# Enable log (otherwise, tor doesn't seemd to deploy onion address)
Log notice file /var/log/tor/notices.log
# Enable single hop routing (2 options below are dependencies of third). Will reduce latency in exchange of anonimity (since tor runs alongside smp-server and onion address will be displayed in clients, this is totally fine)
SOCKSPort 0
HiddenServiceNonAnonymousMode 1
HiddenServiceSingleHopMode 1
# smp-server hidden service host directory and port mappings
HiddenServiceDir /var/lib/tor/simplex-smp/
HiddenServicePort 5223 localhost:5223
```
- Create directories:
```sh
mkdir /var/lib/tor/simplex-smp/ && chown debian-tor:debian-tor /var/lib/tor/simplex-smp/ && chmod 700 /var/lib/tor/simplex-smp/
```
3. Start tor:
Enable `systemd` service and start tor. Offical `tor` is a bit flunky on the first start and may not create onion host address, so we're restarting it just in case.
```sh
systemctl enable tor && systemctl start tor && systemctl restart tor
```
4. Display onion host:
Execute the following command to display your onion host address:
```sh
cat /var/lib/tor/simplex-smp/hostname
```
## Configuration
To see which options are available, execute `smp-server` without flags:

View File

@@ -1,6 +1,6 @@
---
title: Hosting your own XFTP Server
revision: 21.04.2023
revision: 31.07.2023
---
# Hosting your own XFTP Server
@@ -17,26 +17,43 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba
## Installation
1. Download `xftp-server` binary:
0. First, install `xftp-server`:
```sh
sudo curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && sudo chmod +x /usr/local/bin/xftp-server
```
- Manual deployment (see below)
2. Create user and group for `xftp-server`:
- Semi-automatic deployment:
- [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script)
- [Docker container](https://github.com/simplex-chat/simplexmq#using-docker)
Manual installation requires some preliminary actions:
0. Install binary:
- Using offical binaries:
```sh
curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server
```
- Compiling from source:
Please refer to [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution)
1. Create user and group for `xftp-server`:
```sh
sudo useradd -m xftp
```
3. Create necessary directories and assign permissions:
2. Create necessary directories and assign permissions:
```sh
sudo mkdir -p /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp
sudo chown xftp:xftp /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp
```
4. Allow xftp-server port in firewall:
3. Allow xftp-server port in firewall:
```sh
# For Ubuntu
@@ -46,7 +63,7 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba
sudo firewall-cmd --reload
```
5. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content:
4. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content:
```sh
[Unit]
@@ -69,6 +86,86 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba
And execute `sudo systemctl daemon-reload`.
## Tor installation
xftp-server can also be deployed to serve from [tor](https://www.torproject.org) network. Run the following commands as `root` user.
1. Install tor:
We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [offical tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide.
- Configure offical Tor PPA repository:
```sh
CODENAME="$(lsb_release -c | awk '{print $2}')"
echo "deb [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org ${CODENAME} main
deb-src [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org ${CODENAME} main" > /etc/apt/sources.list.d/tor.list
```
- Import repository key:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | tee /usr/share/keyrings/tor-archive-keyring.gpg >/dev/null
```
- Update repository index:
```sh
apt update
```
- Install `tor` package:
```sh
apt install -y tor deb.torproject.org-keyring
```
2. Configure tor:
- File configuration:
Open tor configuration with your editor of choice (`nano`,`vim`,`emacs`,etc.):
```sh
vim /etc/tor/torrc
```
And insert the following lines to the bottom of configuration. Please note lines starting with `#`: this is comments about each individual options.
```sh
# Enable log (otherwise, tor doesn't seemd to deploy onion address)
Log notice file /var/log/tor/notices.log
# Enable single hop routing (2 options below are dependencies of third). Will reduce latency in exchange of anonimity (since tor runs alongside xftp-server and onion address will be displayed in clients, this is totally fine)
SOCKSPort 0
HiddenServiceNonAnonymousMode 1
HiddenServiceSingleHopMode 1
# xftp-server hidden service host directory and port mappings
HiddenServiceDir /var/lib/tor/simplex-xftp/
HiddenServicePort 443 localhost:443
```
- Create directories:
```sh
mkdir /var/lib/tor/simplex-xftp/ && chown debian-tor:debian-tor /var/lib/tor/simplex-xftp/ && chmod 700 /var/lib/tor/simplex-xftp/
```
3. Start tor:
Enable `systemd` service and start tor. Offical `tor` is a bit flunky on the first start and may not create onion host address, so we're restarting it just in case.
```sh
systemctl enable tor && systemctl start tor && systemctl restart tor
```
4. Display onion host:
Execute the following command to display your onion host address:
```sh
cat /var/lib/tor/simplex-xftp/hostname
```
## Configuration
To see which options are available, execute `xftp-server` without flags:

View File

@@ -1,5 +1,5 @@
name: simplex-chat
version: 5.3.0.2
version: 5.3.0.5
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme

View File

@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."82aec2cd8f7b4033dbf08d5de33ced216f574bbb" = "1x3rjq10d3c8qb6wf66a2j127xi9xdg21pyw5r4n124f8yvlb0nc";
"https://github.com/simplex-chat/simplexmq.git"."cf2a17b80ce5736a8b3b02016e3f466f781f259d" = "0yq7kaidnlv9rxl080jv89p8awap046flqzglb71kwy1h1klvyri";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";

View File

@@ -5,12 +5,12 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 5.3.0.2
version: 5.3.0.5
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
maintainer: chat@simplex.chat
copyright: 2020-22 simplex.chat
copyright: 2020-23 simplex.chat
license: AGPL-3
license-file: LICENSE
build-type: Simple
@@ -107,6 +107,7 @@ library
Simplex.Chat.Migrations.M20230621_chat_item_moderations
Simplex.Chat.Migrations.M20230705_delivery_receipts
Simplex.Chat.Migrations.M20230721_group_snd_item_statuses
Simplex.Chat.Migrations.M20230814_indexes
Simplex.Chat.Mobile
Simplex.Chat.Mobile.WebRTC
Simplex.Chat.Options

View File

@@ -48,7 +48,7 @@ import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
import Data.Time.Clock.System (SystemTime, systemToUTCTime)
import Data.Word (Word32)
import qualified Database.SQLite.Simple as DB
import qualified Database.SQLite.Simple as SQL
import Simplex.Chat.Archive
import Simplex.Chat.Call
import Simplex.Chat.Controller
@@ -74,11 +74,13 @@ import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
import Simplex.FileTransfer.Description (ValidFileDescription, gb, kb, mb)
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
import Simplex.Messaging.Agent as Agent
import Simplex.Messaging.Agent.Client (AgentStatsKey (..), temporaryAgentError)
import Simplex.Messaging.Agent.Client (AgentStatsKey (..), agentClientStore, temporaryAgentError)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig)
import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection)
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations
import Simplex.Messaging.Client (defaultNetworkConfig)
import qualified Simplex.Messaging.Crypto as C
@@ -397,7 +399,7 @@ processChatCommand = \case
asks currentUser >>= atomically . (`writeTVar` Just user'')
pure $ CRActiveUser user''
SetActiveUser uName viewPwd_ -> do
tryError (withStore (`getUserIdByName` uName)) >>= \case
tryChatError (withStore (`getUserIdByName` uName)) >>= \case
Left _ -> throwChatError CEUserUnknown
Right userId -> processChatCommand $ APISetActiveUser userId viewPwd_
SetAllContactReceipts onOff -> withUser $ \_ -> withStore' (`updateAllContactReceipts` onOff) >> ok_
@@ -491,6 +493,18 @@ processChatCommand = \case
APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg
ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query)
ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query)
SlowSQLQueries -> do
ChatController {chatStore, smpAgent} <- ask
chatQueries <- slowQueries chatStore
agentQueries <- slowQueries $ agentClientStore smpAgent
pure CRSlowSQLQueries {chatQueries, agentQueries}
where
slowQueries st =
liftIO $
map (uncurry SlowSQLQuery . first SQL.fromQuery)
. sortOn (timeAvg . snd)
. M.assocs
<$> withConnection st (readTVarIO . DB.slow)
APIGetChats userId withPCC -> withUserId userId $ \user ->
CRApiChats user <$> withStoreCtx' (Just "APIGetChats, getChatPreviews") (\db -> getChatPreviews db user withPCC)
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
@@ -1706,7 +1720,7 @@ processChatCommand = \case
QuitChat -> liftIO exitSuccess
ShowVersion -> do
let versionInfo = coreVersionInfo $(simplexmqCommitQ)
chatMigrations <- map upMigration <$> withStore' Migrations.getCurrent
chatMigrations <- map upMigration <$> withStore' (Migrations.getCurrent . DB.conn)
agentMigrations <- withAgent getAgentMigrations
pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations}
DebugLocks -> do
@@ -1781,17 +1795,25 @@ processChatCommand = \case
_ -> throwChatError $ CECommandError "not supported"
connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> m ChatResponse
connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
(_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> drgRandomBytes 16
xContactId <- maybe randomXContactId pure xContactId_
case groupLinkId of
Nothing ->
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
(_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> drgRandomBytes 16
xContactId <- maybe randomXContactId pure xContactId_
connect' Nothing cReqHash xContactId
Just gLinkId -> procCmd $ do
xContactId <- XContactId <$> drgRandomBytes 16
connect' (Just gLinkId) cReqHash xContactId
where
connect' groupLinkId cReqHash xContactId = do
-- [incognito] generate profile to send
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = userProfileToSend user incognitoProfile Nothing
connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId)
let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli
conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId
toView $ CRNewContactConnection user conn
pure $ CRSentInvitation user incognitoProfile
@@ -1959,7 +1981,7 @@ processChatCommand = \case
drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n)
privateGetUser :: UserId -> m User
privateGetUser userId =
tryError (withStore (`getUser` userId)) >>= \case
tryChatError (withStore (`getUser` userId)) >>= \case
Left _ -> throwChatError CEUserUnknown
Right user -> pure user
validateUserPassword :: User -> User -> Maybe UserPwd -> m ()
@@ -3974,7 +3996,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
whenContactNtfs user ct $
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
where
@@ -5040,6 +5062,7 @@ chatCommandP =
"/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP),
"/sql chat " *> (ExecChatStoreSQL <$> textP),
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
"/sql slow" $> SlowSQLQueries,
"/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
@@ -5141,7 +5164,7 @@ chatCommandP =
("/help" <|> "/h") $> ChatHelp HSMain,
("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile),
"/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP),
("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRAdmin)),
("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)),
("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName),
("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole),
("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName),

View File

@@ -60,6 +60,7 @@ import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, UserProtocol, XFTPServerWithAuth)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import Simplex.Messaging.Transport (simplexMQVersion)
import Simplex.Messaging.Transport.Client (TransportHost)
import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors)
@@ -229,6 +230,7 @@ data ChatCommand
| APIStorageEncryption DBEncryptionConfig
| ExecChatStoreSQL Text
| ExecAgentStoreSQL Text
| SlowSQLQueries
| APIGetChats {userId :: UserId, pendingConnections :: Bool}
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems ChatPagination (Maybe String)
@@ -563,6 +565,7 @@ data ChatResponse
| CRNewContactConnection {user :: User, connection :: PendingContactConnection}
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
| CRSQLResult {rows :: [Text]}
| CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]}
| CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks}
| CRAgentStats {agentStats :: [[String]]}
| CRConnectionDisabled {connectionEntity :: ConnectionEntity}
@@ -801,6 +804,14 @@ data SendFileMode
| SendFileXFTP
deriving (Show, Generic)
data SlowSQLQuery = SlowSQLQuery
{ query :: Text,
queryStats :: SlowQueryStats
}
deriving (Show, Generic)
instance ToJSON SlowSQLQuery where toEncoding = J.genericToEncoding J.defaultOptions
data ChatError
= ChatError {errorType :: ChatErrorType}
| ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity}

View File

@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20230814_indexes where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20230814_indexes :: Query
m20230814_indexes =
[sql|
CREATE INDEX idx_chat_items_user_id_item_status ON chat_items(user_id, item_status);
|]
down_m20230814_indexes :: Query
down_m20230814_indexes =
[sql|
DROP INDEX idx_chat_items_user_id_item_status;
|]

View File

@@ -701,3 +701,7 @@ CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON group_snd_item_statuses
CREATE INDEX idx_group_snd_item_statuses_group_member_id ON group_snd_item_statuses(
group_member_id
);
CREATE INDEX idx_chat_items_user_id_item_status ON chat_items(
user_id,
item_status
);

View File

@@ -30,6 +30,7 @@ import Foreign.C.Types (CInt (..))
import Foreign.Ptr
import Foreign.StablePtr
import Foreign.Storable (poke)
import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding)
import GHC.Generics (Generic)
import Simplex.Chat
import Simplex.Chat.Controller
@@ -47,6 +48,7 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..))
import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8)
import System.IO (utf8)
import System.Timeout (timeout)
foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
@@ -70,6 +72,12 @@ foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Wo
-- | check / migrate database and initialize chat controller on success
cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
cChatMigrateInit fp key conf ctrl = do
-- ensure we are set to UTF-8; iOS does not have locale, and will default to
-- US-ASCII all the time.
setLocaleEncoding utf8
setFileSystemEncoding utf8
setForeignEncoding utf8
dbPath <- peekCAString fp
dbKey <- peekCAString key
confirm <- peekCAString conf

View File

@@ -16,7 +16,6 @@ import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Time.Clock (UTCTime (..))
import Database.SQLite.Simple ((:.) (..))
import qualified Database.SQLite.Simple as DB
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Store.Files
import Simplex.Chat.Store.Groups
@@ -25,6 +24,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow')
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
getConnectionEntity :: DB.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
getConnectionEntity db user@User {userId, userContactId} agentConnId = do

View File

@@ -68,13 +68,13 @@ import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.Text (Text)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..))
import qualified Database.SQLite.Simple as DB
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection
getPendingContactConnection db userId connId = do

View File

@@ -83,7 +83,6 @@ import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay)
import Data.Type.Equality
import Database.SQLite.Simple (Only (..), (:.) (..))
import qualified Database.SQLite.Simple as DB
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Messages
@@ -96,6 +95,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Util (week)
import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
getLiveSndFileTransfers db User {userId} = do

View File

@@ -94,7 +94,6 @@ import Data.Maybe (fromMaybe, isNothing)
import Data.Text (Text)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), (:.) (..))
import qualified Database.SQLite.Simple as DB
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Messages
import Simplex.Chat.Store.Direct
@@ -103,6 +102,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Protocol (ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (eitherToMaybe)
import UnliftIO.STM
@@ -242,11 +242,11 @@ getGroupAndMember db User {userId, userContactId} groupMemberId =
LEFT JOIN connections c ON c.connection_id = (
SELECT max(cc.connection_id)
FROM connections cc
where cc.group_member_id = m.group_member_id
where cc.user_id = ? AND cc.group_member_id = m.group_member_id
)
WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ?
|]
(groupMemberId, userId, userContactId)
(userId, groupMemberId, userId, userContactId)
where
toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember)
toGroupAndMember (groupInfoRow :. memberRow :. connRow) =
@@ -530,7 +530,7 @@ groupMemberQuery =
LEFT JOIN connections c ON c.connection_id = (
SELECT max(cc.connection_id)
FROM connections cc
where cc.group_member_id = m.group_member_id
where cc.user_id = ? AND cc.group_member_id = m.group_member_id
)
|]
@@ -540,7 +540,7 @@ getGroupMember db user@User {userId} groupId groupMemberId =
DB.query
db
(groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?")
(groupId, groupMemberId, userId)
(userId, groupId, groupMemberId, userId)
getGroupMemberById :: DB.Connection -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember
getGroupMemberById db user@User {userId} groupMemberId =
@@ -548,7 +548,7 @@ getGroupMemberById db user@User {userId} groupMemberId =
DB.query
db
(groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?")
(groupMemberId, userId)
(userId, groupMemberId, userId)
getGroupMembers :: DB.Connection -> User -> GroupInfo -> IO [GroupMember]
getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do
@@ -556,7 +556,7 @@ getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do
<$> DB.query
db
(groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)")
(groupId, userId, userContactId)
(userId, groupId, userId, userContactId)
getGroupMembersForExpiration :: DB.Connection -> User -> GroupInfo -> IO [GroupMember]
getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {groupId} = do
@@ -572,7 +572,7 @@ getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {gro
)
|]
)
(groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)
(userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)
toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember
toContactMember User {userContactId} (memberRow :. connRow) =
@@ -998,11 +998,11 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
LEFT JOIN connections c ON c.connection_id = (
SELECT max(cc.connection_id)
FROM connections cc
where cc.group_member_id = m.group_member_id
where cc.user_id = ? AND cc.group_member_id = m.group_member_id
)
WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0
|]
(userId, contactId, userContactId)
(userId, userId, contactId, userContactId)
where
toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember)
toGroupAndMember (groupInfoRow :. memberRow :. connRow) =
@@ -1296,11 +1296,11 @@ getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do
LEFT JOIN connections ch ON ch.connection_id = (
SELECT max(cc.connection_id)
FROM connections cc
where cc.group_member_id = mh.group_member_id
where cc.user_id = ? AND cc.group_member_id = mh.group_member_id
)
WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ?
|]
(userId, contactId, GCHostMember)
(userId, userId, contactId, GCHostMember)
where
toCont :: (Int64, GroupId, GroupMemberId, MemberId, Maybe ConnReqInvitation) -> Maybe (Int64, XGrpMemIntroCont)
toCont (hostConnId, groupId, groupMemberId, memberId, connReq_) = case connReq_ of
@@ -1326,11 +1326,11 @@ getXGrpMemIntroContGroup db User {userId} GroupMember {groupMemberId} = do
LEFT JOIN connections ch ON ch.connection_id = (
SELECT max(cc.connection_id)
FROM connections cc
where cc.group_member_id = mh.group_member_id
where cc.user_id = ? AND cc.group_member_id = mh.group_member_id
)
WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0
|]
(userId, groupMemberId, GCHostMember)
(userId, userId, groupMemberId, GCHostMember)
where
toCont :: (Int64, Maybe ConnReqInvitation) -> Maybe (Int64, ConnReqInvitation)
toCont (hostConnId, connReq_) = case connReq_ of

View File

@@ -110,7 +110,6 @@ import Data.Text (Text)
import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..))
import qualified Database.SQLite.Simple as DB
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
@@ -122,6 +121,7 @@ import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import Simplex.Messaging.Util (eitherToMaybe)
import UnliftIO.STM

View File

@@ -75,6 +75,7 @@ import Simplex.Chat.Migrations.M20230618_favorite_chats
import Simplex.Chat.Migrations.M20230621_chat_item_moderations
import Simplex.Chat.Migrations.M20230705_delivery_receipts
import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses
import Simplex.Chat.Migrations.M20230814_indexes
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -149,7 +150,8 @@ schemaMigrations =
("20230618_favorite_chats", m20230618_favorite_chats, Just down_m20230618_favorite_chats),
("20230621_chat_item_moderations", m20230621_chat_item_moderations, Just down_m20230621_chat_item_moderations),
("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts),
("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses)
("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses),
("20230814_indexes", m20230814_indexes, Just down_m20230814_indexes)
]
-- | The list of migrations in ascending order by date

View File

@@ -66,7 +66,6 @@ import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..))
import qualified Database.SQLite.Simple as DB
import Database.SQLite.Simple.QQ (sql)
import GHC.Generics (Generic)
import Simplex.Chat.Call
@@ -78,6 +77,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..))

View File

@@ -25,7 +25,7 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, SQLError, (:.) (..))
import qualified Database.SQLite.Simple as DB
import qualified Database.SQLite.Simple as SQL
import Database.SQLite.Simple.QQ (sql)
import GHC.Generics (Generic)
import Simplex.Chat.Messages
@@ -34,6 +34,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
import Simplex.Messaging.Util (allFinally)
import UnliftIO.STM
@@ -107,7 +108,7 @@ checkConstraint err action = ExceptT $ runExceptT action `E.catch` (pure . Left
handleSQLError :: StoreError -> SQLError -> StoreError
handleSQLError err e
| DB.sqlError e == DB.ErrorConstraint = err
| SQL.sqlError e == SQL.ErrorConstraint = err
| otherwise = SEInternalError $ show e
storeFinally :: ExceptT StoreError IO a -> ExceptT StoreError IO b -> ExceptT StoreError IO a
@@ -309,7 +310,7 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate
E.try (insertName ldn currentTs) >>= \case
Right () -> action ldn
Left e
| DB.sqlError e == DB.ErrorConstraint -> tryCreateName (ldnSuffix + 1) (attempts - 1)
| SQL.sqlError e == SQL.ErrorConstraint -> tryCreateName (ldnSuffix + 1) (attempts - 1)
| otherwise -> E.throwIO e
where
insertName ldn ts =
@@ -335,7 +336,7 @@ createWithRandomBytes size gVar create = tryCreate 3
liftIO (E.try $ create id') >>= \case
Right x -> pure x
Left e
| DB.sqlError e == DB.ErrorConstraint -> tryCreate (n - 1)
| SQL.sqlError e == SQL.ErrorConstraint -> tryCreate (n - 1)
| otherwise -> throwError . SEInternalError $ show e
encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString

View File

@@ -25,7 +25,7 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Database.SQLite.Simple (Only (..))
import qualified Database.SQLite.Simple as DB
import qualified Database.SQLite.Simple as SQL
import Database.SQLite.Simple.QQ (sql)
import GHC.Weak (deRefWeak)
import Simplex.Chat
@@ -36,6 +36,7 @@ import Simplex.Chat.Styled
import Simplex.Chat.Terminal.Output
import Simplex.Chat.Types (User (..))
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore, withTransaction)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import Simplex.Messaging.Util (catchAll_, safeDecodeUtf8, whenM)
import System.Exit (exitSuccess)
import System.Terminal hiding (insertChars)
@@ -299,7 +300,7 @@ updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s,
getNameSfxs table pfx =
getNameSfxs_ pfx (userId, pfx <> "%") $
"SELECT local_display_name FROM " <> table <> " WHERE user_id = ? AND local_display_name LIKE ?"
getNameSfxs_ :: DB.ToRow p => Text -> p -> DB.Query -> IO [String]
getNameSfxs_ :: SQL.ToRow p => Text -> p -> SQL.Query -> IO [String]
getNameSfxs_ pfx ps q =
withTransaction st (\db -> hasPfx pfx . map fromOnly <$> DB.query db q ps) `catchAll_` pure []
commands =

View File

@@ -47,6 +47,7 @@ import qualified Simplex.FileTransfer.Protocol as XFTP
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..))
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
@@ -247,6 +248,13 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
CRNtfMessages {} -> []
CRSQLResult rows -> map plain rows
CRSlowSQLQueries {chatQueries, agentQueries} ->
let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} =
"count: " <> sShow count
<> (" :: max: " <> sShow timeMax <> " ms")
<> (" :: avg: " <> sShow timeAvg <> " ms")
<> (" :: " <> plain (T.unwords $ T.lines query))
in ("Chat queries" : map viewQuery chatQueries) <> [""] <> ("Agent queries" : map viewQuery agentQueries)
CRDebugLocks {chatLockName, agentLocks} ->
[ maybe "no chat lock" (("chat lock: " <>) . plain) chatLockName,
plain $ "agent locks: " <> LB.unpack (J.encode agentLocks)

Some files were not shown because too many files have changed in this diff Show More