Compare commits

...

184 Commits

Author SHA1 Message Date
Evgeny Poberezkin b027708828 4.4.4: Android 92, iOS 118 2023-01-23 20:17:52 +00:00
JRoberts ab4e4e1db9 core: test cancelling inline file transfer (#1827)
* core: test cancelling inline file transfer

* tests
2023-01-23 18:27:44 +00:00
Stanislav Dmitrenko a393bc8163 ios: Testing workaround of a crash (#1789)
* ios: Testing workaround of a crash

* another try

* complete

* added file

* enable swipe to go back from ChatView

* Revert "enable swipe to go back from ChatView"

This reverts commit 22de79505c.

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-01-23 18:17:33 +00:00
Evgeny Poberezkin 3bc4fd222c core: fix cancelling sending inline files (#1826)
* core: fix cancelling sending inline files

* add comments

* typos

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2023-01-23 21:17:42 +04:00
JRoberts 1b01dcec6d core: fix inline file transfer - optional connection ids in RcvFileInfo, update rcv_file_inline on accept (#1823)
* core: optional connection id in RcvFileInfo

* query

* check snd cancel

* Revert "check snd cancel"

This reverts commit f16651345d.

* update rcv_file_inline
2023-01-23 15:55:19 +00:00
Evgeny Poberezkin 5d12217eab 4.4.4: Android 91, iOS 117 2023-01-22 19:30:19 +00:00
Evgeny Poberezkin 4b0046a60b mobile: show version information (#1820)
* mobile: show version information

* export localizations
2023-01-22 18:34:01 +00:00
Evgeny Poberezkin 114b76e3f8 core: update version response (#1819)
* core: update version response

* add simplexmq commit and version to version info

* refactor
2023-01-22 15:16:45 +00:00
Evgeny Poberezkin 8ff8f9d695 core: add build timestamp to version information (#1816) 2023-01-21 22:56:33 +00:00
Evgeny Poberezkin 1e3c2024bb android: add localization of "offered/cancelled" feature items, closes #1766 (#1815) 2023-01-21 16:47:26 +00:00
Evgeny Poberezkin f4ffa5237c 4.4.4-beta.1: Android 90, iOS 116 2023-01-20 12:02:24 +00:00
Evgeny Poberezkin c409f58067 mobile: add PING count to network config, make advanced network config available without dev tools (#1805)
* mobile: add PING count to network config, make advanced network config available without dev tools

* export ios translations

* add 120 sec PING interval back
2023-01-20 10:55:12 +00:00
Evgeny Poberezkin 0a9aa8b3d2 translations: update French and Italian (#1799)
* Translated using Weblate (French)

Currently translated at 100.0% (907 of 907 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (853 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Italian)

Currently translated at 100.0% (907 of 907 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (853 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* import localizations

* re-export localizations

Co-authored-by: Ophiushi <Ophiushi@users.noreply.hosted.weblate.org>
Co-authored-by: random r <epsilin@yopmail.com>
2023-01-19 19:27:38 +00:00
Evgeny Poberezkin 69196084fc core: update simplexmq 2023-01-19 19:17:32 +00:00
Evgeny Poberezkin 7c9c358ce2 ios: update core library 2023-01-18 11:01:51 +00:00
Evgeny Poberezkin 80e0bfb61f core: v4.4.4 2023-01-17 18:10:47 +00:00
Evgeny Poberezkin 19881703e5 core: increase default internal queue max size to 1024 2023-01-17 14:07:47 +00:00
Evgeny Poberezkin 7eca44bb84 4.4.4-beta.0: update simplexmq 2023-01-17 10:09:36 +00:00
Evgeny Poberezkin 774af334fd terminal: command to show the most recent chats (#1756)
* terminal: command to show the list of the last active chats

* indent for chats without messages, help

* update command in the test
2023-01-16 12:10:47 +00:00
Evgeny Poberezkin af414d7f6e terminal: options for log level and internal queue sizes (#1755)
* terminal: log levels

* option for internal queue sizes
2023-01-16 09:13:46 +00:00
Evgeny Poberezkin 9dad55ce8d 4.4.3: iOS 115, Android 89 2023-01-14 11:46:29 +00:00
Evgeny Poberezkin a3283708e7 translations: add Italian, updates (#1741)
* Added translation using Weblate (Italian)

* Added translation using Weblate (Italian)

* Translated using Weblate (French)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 99.6% (850 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Italian)

Currently translated at 24.9% (226 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 26.3% (225 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (German)

Currently translated at 100.0% (906 of 906 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (853 of 853 strings)

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

* Translated using Weblate (Italian)

Currently translated at 28.8% (261 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (French)

Currently translated at 100.0% (853 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Italian)

Currently translated at 58.2% (528 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (German)

Currently translated at 100.0% (907 of 907 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (853 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (German)

Currently translated at 100.0% (907 of 907 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (853 of 853 strings)

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

* Added translation using Weblate (Italian)

* Added translation using Weblate (Italian)

* Translated using Weblate (French)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 99.6% (850 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Italian)

Currently translated at 24.9% (226 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 26.3% (225 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (German)

Currently translated at 100.0% (906 of 906 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (853 of 853 strings)

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

* Translated using Weblate (Italian)

Currently translated at 28.8% (261 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (French)

Currently translated at 100.0% (853 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Italian)

Currently translated at 58.2% (528 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (German)

Currently translated at 100.0% (907 of 907 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (853 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (German)

Currently translated at 100.0% (907 of 907 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (853 of 853 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (907 of 907 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (Italian)

Currently translated at 100.0% (907 of 907 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 38.5% (329 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (907 of 907 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (853 of 853 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* correction

* correction

* correction

* ios: import localizations

* ios: re-export localizations

Co-authored-by: Ophiushi <ptlfr@pm.me>
Co-authored-by: random r <epsilin@yopmail.com>
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Ophiushi <Ophiushi@users.noreply.hosted.weblate.org>
2023-01-13 23:14:01 +00:00
Evgeny Poberezkin e833d66557 Merge branch 'stable' 2023-01-13 19:18:51 +00:00
Stanislav Dmitrenko 71f5b51350 ios: change network config in NSE when updated via UI (#1731)
* ios: Apply changed network config to NSE

* Better

* Separate function

* rename

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-01-13 19:05:53 +00:00
Stanislav Dmitrenko 20cec4db11 mobile: do not reset chat preferences on profile update (#1740)
* android: Do not reset prefs on profile update

* ios: Include prefs into Profile/LocalProfile
2023-01-13 18:57:54 +00:00
Stanislav Dmitrenko 2085dc5d60 android: Fix blocked thread while making live messages (#1739)
* android: Fix blocked thread while making live messages

* Test

* Revert "Test"

This reverts commit bd8a5b6ca0.
2023-01-13 18:33:52 +00:00
Michaël Bitard 6d5c3ae484 Fix typo (#1732) 2023-01-13 15:49:58 +00:00
JRoberts e73f5c40cf 4.4.2: ios 114, Android 88 2023-01-12 18:38:58 +04:00
JRoberts 4c960bdc44 4.4.2 2023-01-12 16:46:28 +04:00
JRoberts dcb82951ed core: catch errors when sending messages in loops where they haven't been previously caught (#1729) 2023-01-12 16:31:27 +04:00
Stanislav Dmitrenko 138cce4436 ios: fix opening via notification returning to chat list when screen lock is enabled (#1725)
* ios: Fixed broken chat push/pop logic

* remove binding parameter

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-01-11 22:48:52 +00:00
Evgeny Poberezkin 98417dafc4 4.4.1: ios 113, Android 87 2023-01-11 17:54:24 +00:00
Evgeny Poberezkin e8374be19c mobile: set defaults consistently (protected screen: iOS off/Android on, accept images: on, faster image transfer: on) (#1724)
* ios: set defaults consistently (protected screen: off, accept images: on, faster image transfer: on)

* android: transfer images faster by default
2023-01-11 17:09:17 +00:00
Stanislav Dmitrenko 62a2f61751 android: Fix non-unique chat item id in listState (#1723)
* android: Fix non-unique chat item id in listState

* Test

* Revert "Test"

This reverts commit 6625bce138.
2023-01-11 16:41:34 +00:00
Evgeny Poberezkin 2d47175f94 ios: disable reply/edit actions and deletion of live item in live mode (#1722) 2023-01-11 17:29:09 +04:00
Evgeny Poberezkin a6d7604d21 mobile: send live message when there is any content (#1721)
* ios: send live message when there is any content

* android: improve live message logic

* fix, refactor

* prohibit live messages with quotes
2023-01-11 12:01:02 +00:00
JRoberts 9e3573fc76 android: fix send button being disabled on images, files & voice messages (#1720)
* android: fix send button being disabled on images, files & voice messages

* rename view

* format
2023-01-11 08:59:04 +00:00
Evgeny Poberezkin 13ebaf587e 4.4.1-beta.1: iOS 112, Android 86 2023-01-10 23:21:37 +00:00
Stanislav Dmitrenko 61e20550bc core: Updated scripts for downloading libs (#1712) 2023-01-10 20:22:18 +00:00
Stanislav Dmitrenko d1cc5c1769 ios: Better check for existing of image's alpha (#1718)
* ios: Better check for existing of image's alpha

* Allow non-transparent pixels

* optimize

* remove prints

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-01-10 19:39:42 +00:00
Stanislav Dmitrenko 16b041c8c6 ios: Live messages without sending an empty text (#1714)
* ios: Live messages without sending an empty text

* Custom Equatable

* Changes

* Change

* Fix liveMessage not hiding

* Refactoring

* Refactoring

* No animation when removing dummy live message item

* Check

* Anim

* Animation

* whitespace

* refactor

* Fix race

* Better fix of race

* fix race condition

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-01-10 19:12:48 +00:00
JRoberts 810f248c74 core: test async file transfer (sender & receiver restarts); close files in stopChatController; handle openFile error in getFileHandle (#1716) 2023-01-10 20:52:59 +04:00
JRoberts 813fecddfe core: fix live file transfers queries (#1715) 2023-01-10 16:22:21 +04:00
Stanislav Dmitrenko 5a7d61c964 android: Live messages without sending an empty text (#1709)
* android: Live messages without sending an empty text

* Better quoted messages handling

* Do not add item into preview

* Change

* Changes
2023-01-09 18:30:26 +00:00
Evgeny Poberezkin cba24983e6 4.4.1-beta.0: iOS 111, Android 85 2023-01-08 16:41:40 +00:00
Evgeny Poberezkin 4dc2a1b72d Revert "core: include commit information in /v response (#1705)"
This reverts commit 3d4e4e2ef9.
2023-01-08 13:13:13 +00:00
Evgeny Poberezkin 3d4e4e2ef9 core: include commit information in /v response (#1705) 2023-01-07 16:38:35 +00:00
JRoberts 113c67ec95 core: disable connections on repeat AUTH errors (#1704) 2023-01-07 19:47:51 +04:00
Stanislav Dmitrenko a2e887024f android: Fixed compatibility with API 29 (#1674) 2023-01-07 14:24:28 +00:00
JRoberts 37262b3ed5 core: fix view agent error context text (#1700) 2023-01-06 19:58:03 +04:00
Evgeny Poberezkin dca4fe7701 Merge branch 'stable' 2023-01-06 13:16:21 +00:00
Evgeny Poberezkin 88c9334d18 readme: SOL address for donations 2023-01-06 13:14:26 +00:00
JRoberts 58f06aa821 core: print error context on store internal errors (#1699) 2023-01-06 14:22:16 +04:00
JRoberts ae5deab8d3 core: print error context on agent errors (#1697) 2023-01-06 13:11:21 +04:00
Evgeny Poberezkin edfece3206 core: test for live messages (#1694) 2023-01-05 09:08:31 +00:00
Evgeny Poberezkin c32cf8055d ios: update library (better quote error handing) 2023-01-04 20:51:16 +00:00
Evgeny Poberezkin 72ec03a822 ios: localize "feature offered/cancelled" items (#1689)
* ios: localize "feature offered/cancelled" items

* import
2023-01-04 20:48:23 +00:00
Evgeny Poberezkin d89e0efedd translations: ios German, French (#1687)
* translations: ios German, French

* reexport ios
2023-01-04 20:36:50 +00:00
Evgeny Poberezkin 707e8592d9 translations: German, French (#1682)
* Translated using Weblate (German)

Currently translated at 100.0% (906 of 906 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (850 of 850 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (850 of 850 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (German)

Currently translated at 100.0% (906 of 906 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (850 of 850 strings)

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

Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Ophiushi <ptlfr@pm.me>
2023-01-04 20:25:33 +00:00
Evgeny Poberezkin a1b27e9a99 update simplexmq (better handling of quota errors) 2023-01-04 15:07:33 +00:00
Evgeny Poberezkin f68d8fd97c update readme 2023-01-03 13:13:51 +00:00
Evgeny Poberezkin abff42a264 blog: v4.4 (#1675)
* blog: v4.4

* add images

* update post

* update readme, roadmap

* corrections

* correction
2023-01-03 13:07:30 +00:00
Evgeny Poberezkin c1ced70836 Merge branch 'stable' 2023-01-01 14:06:51 +00:00
Evgeny Poberezkin e14966d36e blog: v4.4 release announcement placeholder 2023-01-01 14:05:05 +00:00
Evgeny Poberezkin 97943fc609 ios: v4.4 release, build 110 2023-01-01 14:01:21 +00:00
shum 0f143b2e77 nix: bump lock 2022-12-31 19:42:31 +00:00
Evgeny Poberezkin be10dcbcfc ios: build 109 2022-12-31 19:40:43 +00:00
Evgeny Poberezkin 97dbec927c ios: build 108 2022-12-31 18:23:26 +00:00
Evgeny Poberezkin b4879ca2a3 ios: fix user chat preferences view 2022-12-31 13:38:55 +00:00
Evgeny Poberezkin 15884c0169 4.4.0: iOS 107, Android 84 2022-12-31 11:47:59 +00:00
Evgeny Poberezkin 9f2d5486b6 mobile: fix race condition with live messages (when message is "revived" when it is being sent as no longer live – possibly core should reject "live" updates to non-live messages too) (#1668)
* mobile: fix race condition with live messages (when message is "revived" when it is being sent as no longer live – possibly core should reject "live" updates to non-live messages too)

* android: move delay for live messages
2022-12-31 10:25:32 +00:00
JRoberts 80f0108b41 ios: correctly update chat when opening from another chat via notification (#1667) 2022-12-30 21:47:11 +04:00
Evgeny Poberezkin c37a7ebfe7 ios: import/export localizations 2022-12-30 17:05:32 +00:00
Evgeny Poberezkin 02c2c65d41 translations (#1661)
* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (904 of 904 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (904 of 904 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Russian)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (904 of 904 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (904 of 904 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Russian)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (850 of 850 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 99.6% (847 of 850 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (850 of 850 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (German)

Currently translated at 90.9% (824 of 906 strings)

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

* Translated using Weblate (German)

Currently translated at 92.4% (786 of 850 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (904 of 904 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (904 of 904 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Russian)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (845 of 845 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (850 of 850 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 99.6% (847 of 850 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (906 of 906 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (850 of 850 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (German)

Currently translated at 90.9% (824 of 906 strings)

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

* Translated using Weblate (German)

Currently translated at 92.4% (786 of 850 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (906 of 906 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (850 of 850 strings)

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

Co-authored-by: Ophiushi <ptlfr@pm.me>
Co-authored-by: mlanp <github@lang.xyz>
2022-12-30 17:02:49 +00:00
JRoberts 7c4700b238 ios: check chats not empty before showing lock notice (#1666) 2022-12-30 16:17:56 +00:00
JRoberts 54190ffff9 core: add db indexes for faster group deletion (#1664) 2022-12-30 16:34:42 +04:00
Evgeny Poberezkin 17eed9662e ios: export localizations 2022-12-29 23:31:35 +00:00
Stanislav Dmitrenko bb116bccb4 android: Fallback to manual parsing of apiChats and apiChat responses (#1660)
* android: Fallback to manual parsing of apiChats and apiChat responses

* Different icon

* eol
2022-12-29 22:27:08 +00:00
JRoberts 6cc267689e ios: fallback to manual parsing of apiChats and apiChat responses (#1659) 2022-12-29 18:15:19 +04:00
Evgeny Poberezkin 0e6909845f mobile: preserve group description in profile (#1658) 2022-12-28 16:59:25 +00:00
JRoberts 96ad9faa85 docs: user profiles rfc (#1656) 2022-12-28 16:28:07 +04:00
Evgeny Poberezkin 768c497025 readme: change group links (#1657) 2022-12-28 11:30:57 +00:00
Evgeny Poberezkin 3ec29d8ef4 4.4-beta.4: ios 106, android 83 (fixes wrong type/ios crash) 2022-12-27 20:02:43 +00:00
JRoberts 6c4b92531f android: version 4.4-beta.3 (82) 2022-12-27 20:56:23 +04:00
JRoberts 46d6159da5 ios: version 4.4 beta (105) 2022-12-27 20:48:34 +04:00
JRoberts aab6e1c52f ios, android: set ttl to 1 day when accepting timed messages w/t configured ttl (#1654) 2022-12-27 19:24:33 +04:00
Evgeny Poberezkin c0a01318b5 mobile: French translations (#1655)
* mobile: items with feature offers (#1623)

* mobile: items with feature offers

* ios interactive contact/user preference change items

* android: interactive preference items

* Translated using Weblate (French)

Currently translated at 8.1% (68 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (784 of 784 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.4% (87 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.5% (88 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 14.2% (122 of 855 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 15.9% (137 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 18.2% (157 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 36.3% (312 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 54.0% (464 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 81.0% (695 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 82.1% (705 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 85.9% (756 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* import/export localizations

Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-27 13:21:55 +00:00
Evgeny Poberezkin 13090ff6ed core: do not log TLS handshake errors by default, option to enable (#1652)
* core: do not log TLS handshake errors by default, option to enable

* update simplexmq
2022-12-27 12:05:13 +00:00
Evgeny Poberezkin 90a20cd52f mobile: change live message button to lightning bolt (#1653) 2022-12-27 09:18:20 +00:00
Evgeny Poberezkin 74245d3f2b core: agent stats (#1650) 2022-12-26 22:24:34 +00:00
JRoberts e48452ccff android: show what is new in the latest version (#1651) 2022-12-26 21:46:56 +04:00
JRoberts 39370ba1ef ios: fix - restore Save button in voice message context menu (#1647) 2022-12-26 14:08:58 +00:00
Evgeny Poberezkin a02cfb4f41 ios: show what is new in the latest version (#1644)
* ios: show what is new in the latest version

* add OK button to WhatsNew

* separate state for nav buttons
2022-12-26 14:08:01 +00:00
JRoberts 4370012b8a ios: fix navigation to member info view (#1648) 2022-12-26 13:45:02 +00:00
JRoberts 20c33aea72 android: show connect via url alert in chat list instead of notifications mode on fresh app (#1649) 2022-12-26 13:43:26 +00:00
Evgeny Poberezkin c11a1aa0e6 ios: disallow editing text attributes 2022-12-25 13:58:07 +00:00
Evgeny Poberezkin 166b789f3c ios: v4.4 beta (104) 2022-12-25 09:48:25 +00:00
Evgeny Poberezkin bbc26e272c v4.4-beta.2: android (81) 2022-12-24 21:59:55 +00:00
Evgeny Poberezkin 6c839f8075 android: fix voice recording in groups 2022-12-24 21:47:58 +00:00
Evgeny Poberezkin be91f97c83 ios: disable screen protection by default 2022-12-24 15:41:31 +00:00
Evgeny Poberezkin e085cb7350 4.4-beta.1: iOS 103, Android 80 2022-12-24 11:59:28 +00:00
Evgeny Poberezkin 12574bed96 ios: move image utils to app (#1642)
* ios: move image utils to app

* name in comments
2022-12-24 11:38:59 +00:00
Evgeny Poberezkin 2137893111 ios: change disappearing messages icon (#1641) 2022-12-24 09:48:27 +00:00
Evgeny Poberezkin e552a28a4d ios: re-export French translations 2022-12-23 22:24:27 +00:00
Evgeny Poberezkin b20031d875 ios: add French language (#1640) 2022-12-23 22:14:47 +00:00
Evgeny Poberezkin 558b3fa356 translations (#1639)
* Translated using Weblate (German)

Currently translated at 95.0% (783 of 824 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (824 of 824 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 100.0% (824 of 824 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/
2022-12-23 21:49:34 +00:00
Stanislav Dmitrenko cb337cef10 ios: Download libs script (#1634) 2022-12-23 21:24:08 +00:00
Stanislav Dmitrenko cd63f81292 ios: Animated images (GIF) support (#1636)
* ios: Animated images (GIF) support

* Moved from String path to UIImage param

* Aspect ratio

* Image frame

* gif image size

* refactor

* refactor

* fix fullscreen scroll animation

* rename UploadContent -> AnyImage

* refactor, allow using gifs in profiles

* rename back

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-23 21:22:12 +00:00
Evgeny Poberezkin 6205b03943 ios: localize ttl in disappearing messages, translations (#1638)
* ios: localize ttl in disappearing messages, translations

* more translation keys
2022-12-23 19:55:45 +00:00
Evgeny Poberezkin 82924ce8c6 translations (#1637)
* Translated using Weblate (German)

Currently translated at 93.5% (823 of 880 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (German)

Currently translated at 96.2% (774 of 804 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Russian)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (804 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (880 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

Co-authored-by: J R <jr@simplex.chat>
Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-23 18:55:14 +00:00
Evgeny Poberezkin b1067c339c android: fix meta layout/reserved space (#1635) 2022-12-23 17:27:41 +00:00
Evgeny Poberezkin 0d6e4b48f6 translations (#1633)
* Translated using Weblate (French)

Currently translated at 97.6% (785 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 97.6% (785 of 804 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/
2022-12-23 14:50:23 +00:00
JRoberts 84d2c408ce core: optimize chat loading time - faster chat previews queries (item_status index for chat stats), fix live file transfers queries (#1630)
* core: optimize get chat previews queries (item_status index for chat stats)

* cleanup

* optimize chat loading time

* cleanup

* schema

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-23 18:37:02 +04:00
Evgeny Poberezkin de434b730e weblate/translations (#1632)
* mobile: items with feature offers (#1623)

* mobile: items with feature offers

* ios interactive contact/user preference change items

* android: interactive preference items

* Translated using Weblate (French)

Currently translated at 8.1% (68 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (784 of 784 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.4% (87 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 10.5% (88 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 14.2% (122 of 855 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 15.9% (137 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 18.2% (157 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 36.3% (312 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 54.0% (464 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 81.0% (695 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 82.1% (705 of 858 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 85.9% (756 of 880 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* revert changes

Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-23 14:11:12 +00:00
Evgeny Poberezkin 1251dbc4b0 ios: export localizations 2022-12-23 13:13:37 +00:00
sh d115ad228b build-android: disable external repo (#1615) 2022-12-23 13:10:36 +00:00
Evgeny Poberezkin 28d6f62b74 mobile: better layout for feature/preference items (#1631)
* mobile: better layout for feature/preference items

* refactor
2022-12-23 13:10:00 +00:00
Evgeny Poberezkin 2b9238144b ios: fix double unread status (#1629) 2022-12-23 09:14:12 +00:00
Evgeny Poberezkin a2e1b7ae0a v4.4: iOS build 102, android build 79 2022-12-23 08:33:57 +00:00
Evgeny Poberezkin a00bb6d5ef android: fix enabling voice cancelling disappearing messages 2022-12-22 22:08:21 +00:00
Evgeny Poberezkin da12b651e4 4.4.0 2022-12-22 21:31:07 +00:00
Evgeny Poberezkin a936c14cf2 mobile: items with feature offers (#1627)
* mobile: items with feature offers

* ios interactive contact/user preference change items

* android: interactive preference items

* add missing view

* revert change
2022-12-22 21:01:29 +00:00
Stanislav Dmitrenko e6aad24e5f android: Timed messages TTL logic in preferences (#1624)
* android: Timed messages TTL logic in preferences

* do not set ttl in global timed message prefs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-22 15:39:24 +00:00
JRoberts 8dac96f415 core: wait chat started in deleteTimedItem threads (#1626) 2022-12-22 19:18:38 +04:00
Evgeny Poberezkin aae0802ec8 core: chat items with offered feature (#1620)
* core: chat items with offered feature

* texts

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* new preference items

* test

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-22 14:56:29 +00:00
JRoberts 74a20ef70c core: sort chat previews by chat ts (#1625) 2022-12-22 17:43:10 +04:00
Evgeny Poberezkin a2a29628a7 ios: remove unused code (#1621) 2022-12-21 22:36:05 +00:00
Stanislav Dmitrenko 0b046315ac android: Disappearing messages (#1619)
* android: Disappearing messages

* remove unused func

* remove paren

* outlined timer in meta

* reserving space for meta takes into account ttl text

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-21 22:07:37 +00:00
Stanislav Dmitrenko 372d7ffaa9 ios: Better check for alpha channel existing (#1616)
* ios: Better check for PNG

* Renamed
2022-12-21 16:05:45 +00:00
JRoberts ece928d57e core: update ttl in contact user preference on profile update, fix api, tests; fix global user preferences not being updated in controller state (#1617) 2022-12-21 19:54:44 +04:00
Evgeny Poberezkin e1740a8be4 ios: disappearing messages (#1614)
* ios: disappearing messages

* show ttl in meta if different

* mark messages as disappearing when read

* previews
2022-12-21 12:59:45 +00:00
Evgeny Poberezkin 36eba01ef4 ios: fix padding in send button context menu (#1618) 2022-12-21 12:55:59 +00:00
JRoberts 9e045a44db Revert "core: confirm ttl change to ensure consistent setting (#1587)"
This reverts commit 34e08b2058.
2022-12-21 14:10:05 +04:00
Stanislav Dmitrenko b7d42ef889 ios: Png images support with alpha (#1613)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-21 00:15:18 +00:00
Stanislav Dmitrenko e55cd82ec3 android: Live messages (#1612)
* android: Live messages

* White color

* Spacer

* button sizes

* Do not show voice button in live mode

* Add text to the last image in a row

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-20 23:55:01 +00:00
JRoberts 34e08b2058 core: confirm ttl change to ensure consistent setting (#1587)
* core: confirm ttl change to ensure consistent setting

* wip

* confirm_pref_pending

* xInfo

* test api

* send confirmPrefProfile

* refactor

* don't return contact

* refactor profile update

* refactor further

* refactor further

* refactor xInfo

* refactor xInfo further

* refactor
2022-12-20 22:00:46 +04:00
Evgeny Poberezkin 5e9b7366cc core: refactor chat item updates (#1611)
* core: refactor chat item updates

* removed unused function

* refactor

* refactor
2022-12-20 12:58:15 +00:00
Evgeny Poberezkin 64fb1f0b85 core: do not start disappearing timer for live messages until they stop being live, and start it on item update instead, provided they are read (#1609)
* core: do not start disappearing timer for live messages until they stop being live, and start it on item update instead, provided they are read

* change delays in tests

* diffInSeconds
2022-12-20 10:17:29 +00:00
JRoberts 84e43c57f6 core: ttl in feature chat items, view responses (#1595)
* core: ttl in feature chat items, view responses

* fix tests

* fix test

* view

* refactor

* use prefChangedValue

* use groupPrefChangedValue

* use cupIntValue

* simplify types

* groupFeatureState

* groupPrefToText

* prefToText, view

* remove prefFeature

* rename intValue -> param

* int -> param

* timedTTLText

* remove pragma

* restore pragma

* simplify

* timedTTLText

* fix tests

* off, after

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-19 21:18:59 +04:00
Evgeny Poberezkin ffa37b1684 terminal: use Ctrl-L to start/continue live message, as Alt-Enter is not always supported (#1607)
* terminal: use Ctrl-L to start/continue live message, as Alt-Enter is not always supported

* refactor
2022-12-19 13:05:54 +00:00
Evgeny Poberezkin 86271fe109 terminal: support live messages (#1597)
* terminal: toggle live message updates

* terminal: send live messages (#1599)

* terminal: send live messages

* show edited messages

* send and continue live message with Alt-Enter

* truncate live messages to full words

* remove comments

* refactor

* refactor to avoid clearing live message prompt and show it faster

* $

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-19 11:16:50 +00:00
JRoberts 5dab099b5c core: increase thread delays in timed messages tests 2022-12-19 11:21:51 +04:00
sh 199e61e5c6 docs/webrtc: add troubleshoot section (#1602) 2022-12-18 23:42:37 +00:00
Evgeny Poberezkin 76b4fd34c1 ios: fix live messages sending incomplete words, refactor (#1604) 2022-12-18 21:20:39 +00:00
Evgeny Poberezkin b159496257 mobile: allow ending live message with an empty string (#1603) 2022-12-18 19:21:13 +00:00
Evgeny Poberezkin c0fb29d5f7 ios: remove unused package from project (#1598) 2022-12-17 18:52:02 +00:00
Evgeny Poberezkin 4ab7e5e1c8 terminal: command to get last item ID (to reference it in the tests) (#1596)
* terminal: command to get last item ID (to reference it in the tests)

* lastItemId

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-17 19:33:58 +04:00
Evgeny Poberezkin 9e847c2e1f ios: live messages (#1569)
* ios: live messages

* remove comments

* remove conflict

* live message buttons and alert

* only send full words

* fix double sending

* typing indicator in live items

* add live parameter to API

* typing indication, pass live parameter to API

* refactor to support live messages with attachments

* disable attachments
2022-12-17 14:02:07 +00:00
Evgeny Poberezkin d105e59655 core: set item that was live as 0, that was never live as NULL (Maybe Bool type) (#1594)
* core: set item that was live as 0, that was never live as NULL (Maybe Bool type)

* fix
2022-12-17 13:58:16 +00:00
JRoberts f128ebac87 core: timed messages terminal api, tests (#1591) 2022-12-17 14:49:03 +04:00
Stanislav Dmitrenko b4de9c266b ios: Ability to add stickers (#1593)
* ios: Ability to add stickers

* fix text alignment for correct input field height

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-17 09:01:49 +00:00
JRoberts e410fc7736 core: fix mark read queries (#1592) 2022-12-16 14:32:37 +00:00
Evgeny Poberezkin f5bd6eb4c3 core: do not mark live items as editied until it's no longer live (#1590)
* core: do not mark live items as editied

* Update src/Simplex/Chat/Store.hs

* mark item as edited when it stops being live
2022-12-16 12:31:35 +00:00
JRoberts cee403c1ed core: simplify terminal mark messages read logic (#1589) 2022-12-16 15:56:16 +04:00
Evgeny Poberezkin 8786e2147a core, mobile: logic for enabling disappearing messages (#1588)
* core: logic for enabled for disappearing messages

* refactor

* update feature enabled in UI
2022-12-16 10:27:59 +00:00
Evgeny Poberezkin 6b8705e9f4 core: support for live messages (#1577) 2022-12-16 11:51:04 +04:00
Stanislav Dmitrenko acfb98bd81 android: Optimized chats snapshotFlow (#1578)
* android: Optimized chats snapshotFlow

* Concurrency test

* Revert "Concurrency test"

This reverts commit 911dd0c2ef.

* Comment

* Better catch
2022-12-15 19:57:57 +00:00
Stanislav Dmitrenko c240456b80 android: Progress indicator and group members loading (#1579)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-15 19:29:17 +00:00
Stanislav Dmitrenko 9e1641a154 android: Changed a path in script for downloading libs (#1580) 2022-12-15 19:25:49 +00:00
JRoberts 17cd3cdca4 core: make ttl optional in TimedMessagesPreference (#1583)
* core: make ttl Maybe in TimedMessagesPreference

* omitNothingFields
2022-12-15 18:11:08 +00:00
JRoberts aa264690ab core: add ttl to XMsgUpdate (#1581) 2022-12-15 17:29:46 +04:00
JRoberts 0e837ae392 core: timed messages (#1561)
* docs: disappearing messages rfc

* change schema

* word

* wip

* wip

* todos

* todos

* remove cancel, refactor

* revert prefs

* CITimed

* schema

* time on send direct

* time on send group

* add ttl to msg container, refactor

* timed on receive

* time on read

* getTimedItems, fix tests

* mark read in terminal - view, input, output, fix tests

* refactor

* comment

* util

* insert atomically

* refactor

* use guards

* refactor startTimedItemThread

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-15 15:17:29 +04:00
Stanislav Dmitrenko 68525b4131 android: Show error instead of crashing after failed to parse chats (#1573)
* android: Show error instead of crashing after failed to parse chats

* Just for test

* update strings

* Revert "Just for test"

This reverts commit f9c9a20ab6.

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-15 10:56:20 +00:00
Stanislav Dmitrenko 8775db7c97 android: Create group link with one click (#1575)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-14 22:47:24 +00:00
Stanislav Dmitrenko f266debd56 android: Verify connection security code (#1567)
* android: Verify connection security code

* Dividers

* Changes

* Padding

* Share connection code

* Share connection code

* Unused

* icon sizes

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-14 14:44:26 +00:00
Evgeny Poberezkin 044c7a8191 mobile: update types for timed messages preference (#1574) 2022-12-14 13:53:31 +00:00
Evgeny Poberezkin 677c6aeb2e core: types for timed and live messages (#1572)
* core: types for timed and live messages

* add protocol tests
2022-12-14 16:16:11 +04:00
Evgeny Poberezkin 7b8f5be821 core: type for group preference for timed messages (#1568)
* core: type for group preference for timed messages

* remove unused func
2022-12-14 12:30:24 +04:00
Evgeny Poberezkin 21765905a7 ios: create group link with one click (#1566)
* ios: create group link with one click

* line break

* move call
2022-12-13 17:15:45 +00:00
Evgeny Poberezkin 70a9c01477 translations (#1563)
* Translated using Weblate (Russian)

Currently translated at 100.0% (831 of 831 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (French)

Currently translated at 98.4% (772 of 784 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/
2022-12-13 14:53:06 +00:00
Evgeny Poberezkin 678dbec3e2 core: different types for chat preferences, to allow parameters (#1565) 2022-12-13 14:52:34 +00:00
Stanislav Dmitrenko bd4c7dffbf android: Notification mode selection in onboarding stage (#1535)
* android: Notification mode selection in onboarding stage

* Change

* Different texts

* Disable service starting until on-boarding finishes

* refactor, change strings

* update layout

* update layout

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 19:27:28 +00:00
Stanislav Dmitrenko 1eb4030080 android: Fix crash on multiple selection of images (#1560)
* android: Fix crash on multiple selection of images

* Revert "android: Fix crash on multiple selection of images"

This reverts commit 11a6113b4f.

* Disable image selection when there are images already selected

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 17:27:14 +00:00
sh bcc64442e9 nix: merge android and ios (#1551) 2022-12-12 15:44:49 +00:00
Stanislav Dmitrenko 1246b9e376 android: Chat auto-scrolling behaviour (#1556)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 15:34:26 +00:00
Evgeny Poberezkin d6e9a87d58 ios: verify connection translations (#1558) 2022-12-12 12:50:15 +00:00
Evgeny Poberezkin cddd3cd673 FR translations (#1559)
* Translated using Weblate (German)

Currently translated at 100.0% (822 of 822 strings)

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

* Added translation using Weblate (French)

* Added translation using Weblate (French)

* Translated using Weblate (French)

Currently translated at 0.0% (0 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 0.3% (3 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 0.9% (8 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-12 12:33:50 +00:00
JRoberts e00ef7c7da core: improve stability of file transfer handshake by using async agent commands (#1541) 2022-12-12 16:33:07 +04:00
Evgeny Poberezkin 1a201cfadf translations: corrections to DE, add FR (#1557)
* Translated using Weblate (German)

Currently translated at 100.0% (822 of 822 strings)

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

* Added translation using Weblate (French)

* Added translation using Weblate (French)

* Translated using Weblate (French)

Currently translated at 0.0% (0 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 2.0% (16 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (French)

Currently translated at 0.3% (3 of 822 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Ophiushi <ptlfr@pm.me>
2022-12-12 11:49:08 +00:00
JRoberts a4ecb41743 ios, android: show send direct message button only for active members (#1554) 2022-12-12 15:27:52 +04:00
Stanislav Dmitrenko e347f5329c android: Different style for voice button when no permissions granted (#1555)
* android: Different style for voice button when no permissions granted

* linebreaks

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 10:25:49 +00:00
Stanislav Dmitrenko 741b3e8848 android: Voice messages refactoring (#1511)
* android: Voice messages refactoring

* Different way to block text field from editing while recording voice

* Limited voice record max duration

* Better end of recording when it reaches timeout

* New way of doing things

* Change

* Change

* Stop event refactor

* Stopped state

* Replaced some helpers

* Replaced calls in when()

* Comments

* Change

* Change

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-12 09:36:37 +00:00
Evgeny Poberezkin 7b4710d198 ios: verify connection security code (#1542)
* ios: verify connection security code

* verification in member sheet (still crashes)

* use navigation view for members list

* ios: show verified status in the lists

* update verification status in the list of members

* verified shield layout

* update icon, make add member navigation to right

* refactor chatPreviewTitle
2022-12-12 08:59:35 +00:00
sh c77f6100c5 fdroid: fix metadata (#1550)
* fdroid: fix description

* add header

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-11 18:33:56 +00:00
208 changed files with 28870 additions and 4029 deletions
+23 -18
View File
@@ -86,13 +86,15 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent updates:
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration.](./blog/20221206-simplex-chat-v4.3-voice-messages.md)
[Jan 03, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md).
[All updates](./blog)
@@ -149,7 +151,7 @@ What is already implemented:
We plan to add soon:
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
@@ -191,17 +193,21 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ View deleted messages, full message deletion by sender (with recipient opt-in per contact).
- ✅ Block screenshots and view in recent apps.
- ✅ Advanced server configuration.
- ✅ Disappearing messages (with recipient opt-in per-contact).
- ✅ "Live" messages.
- ✅ Contact verification via a separate out-of-band channel.
- 🏗 Multiple user profiles in the same chat database.
- 🏗 Optionally avoid re-using the same TCP session for multiple connections.
- 🏗 File server to optimize for efficient and private sending of large files.
- 🏗 SMP queue redundancy and rotation (manual is supported).
- 🏗 Contact verification via a separate out-of-band channel.
- 🏗 Ephemeral/disappearing/OTR conversations with the existing contacts.
- Optionally avoid re-using the same TCP session for multiple connections.
- 🏗 Reduced battery and traffic usage in large groups.
- 🏗 Preserve message drafts.
- 🏗 Support older Android OS and 32-bit CPUs.
- Ephemeral/disappearing/OTR conversations with the existing contacts.
- Access password/pin (with optional alternative access password).
- Media server to optimize sending large files to groups.
- Video messages.
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
- Multiple user profiles in the same chat database.
- Feeds/broadcasts.
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
- Web widgets for custom interactivity in the chats.
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
- Supporting the same profile on multiple devices.
@@ -213,24 +219,22 @@ If you are considering developing with SimpleX platform please get in touch for
## Join a user group
You can join a general group with more than 100 members: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D).
You can join a general English speaking group: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FcIS0gu1h0Y8pZpQkDaSz7HZGSHcKpMB9%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAKzzWAJYrVt1zdgRp4pD3FBst6eK7233DJeNElENLJRA%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%228mazMhefXoM5HxWBfZnvwQ%3D%3D%22%7D).
You can also join smaller groups by countries/languages: [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FmIorjTDPG24jdLKXwutS6o9hdQQRZwfQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA9N0BZaECrAw3we3S1Wq4QO7NERBuPt9447immrB50wo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22S8aISlOgkTMytSox9gAM2Q%3D%3D%22%7D) (German), [\#SimpleX-US](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FlTWmQplLEaoJyHnEL1-B3f2PtDsikcTs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-hMBlsQjNxK2vaVhqW_UyAVtuoYqgYTigK4B9dJ9CGc%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22G0UtRHIn0TmPoo08h_cbTA%3D%3D%22%7D) (US/English), [\#SimpleX-France](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F11r6XyjwVMj0WDIUMbmNDXO996M_EN_1%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAXDmc2Lrj9WQOjEcWa0DeQHF3HcYOp9b68s8M_BJ7gEk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22EZCeSYpeIBkaQwCcpcF00w%3D%3D%22%7D), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FZSYM278L5WoZiApx3925EAjSXcsAVNVu%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA7RJ2wfT8zdfOLyE5OtWLEAPowj-q6F2HB0ExbATw8Gk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22fsVoklNGptt7n-droqJYUQ%3D%3D%22%7D) (Russian), [#SimpleX-NL](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmP0LbswSbfxoVkkxiWE2NYnBCgZ9Snvj%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAVwZuSsw4Mf52EaBNdNI3RebsLm0jg65ZIkcmH9E5uy8%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22M9xIULUNZx51Wsa5Kdb0Sg%3D%3D%22%7D) (Netherlands/Dutch), [#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaZ_wjh6QAYHB-LjyGtp8bllkzoq880u-%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-_Wulzc3j16i7t77XJ5wgwxeW8_Ea8GxetMo7K4MgjI%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22QWmXdrFzIeMd2OoEPMFkBQ%3D%3D%22%7D) (Italian).
Groups in languages other than English, that we have app interface translated into: [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian speaking).
You can join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
Let us know if you'd like to add some other countries to the list.
Join via the app to share what's going on and ask any questions!
## Contribute
We would love to have you join the development! You can contribute to SimpleX Chat with:
- developing features - please connect to us via chat so we can help you get started.
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- translate UI to some language - we are currently setting up the UI to simplify it, please get in touch and let us know if you would be able to support and update the translations.
- translate UI to your language - we are using [Weblate](https://hosted.weblate.org/projects/simplex-chat/) to translate the interface, please get in touch if you want to contribute!
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- developing features - please connect to us via chat so we can help you get started.
## Help us with donations
@@ -250,6 +254,7 @@ It is possible to donate via:
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
Thank you,
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 78
versionName "4.3.2"
versionCode 92
versionName "4.4.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -42,7 +42,6 @@ import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class MainActivity: FragmentActivity() {
@@ -395,6 +394,7 @@ fun MainPage(
}
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
ModalManager.shared.showInView()
val invitation = chatModel.activeCallInvitation.value
@@ -478,7 +478,6 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
// TODO open from chat list view
chatModel.appOpenUrl.value = uri
} else {
withUriAction(uri) { linkType ->
@@ -39,7 +39,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
var isAppOnForeground: Boolean = false
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
val res: DBMigrationResult = kotlin.runCatching {
@@ -115,8 +115,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
* It can happen when app was started and a user enables battery optimization while app in background
* */
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
if (chatModel.chatRunning.value != false &&
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
) {
SimplexService.start(applicationContext)
}
}
else -> isAppOnForeground = false
}
@@ -2,8 +2,11 @@ package chat.simplex.app.model
import android.net.Uri
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
@@ -14,6 +17,7 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationPreviewMode
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.datetime.*
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
@@ -21,6 +25,8 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
import java.io.File
import kotlin.random.Random
import kotlin.time.*
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
@@ -101,7 +107,7 @@ class ChatModel(val controller: ChatController) {
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directContact)
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
@@ -151,7 +157,7 @@ class ChatModel(val controller: ChatController) {
}
}
fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
@@ -175,11 +181,17 @@ class ChatModel(val controller: ChatController) {
}
// add to current chat
if (chatId.value == cInfo.id) {
chatItems.add(cItem)
withContext(Dispatchers.Main) {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
} else {
chatItems.add(cItem)
}
}
}
}
fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
@@ -206,7 +218,9 @@ class ChatModel(val controller: ChatController) {
chatItems[itemIndex] = cItem
return false
} else {
chatItems.add(cItem)
withContext(Dispatchers.Main) {
chatItems.add(cItem)
}
return true
}
} else {
@@ -250,6 +264,20 @@ class ChatModel(val controller: ChatController) {
}
}
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
withContext(Dispatchers.Main) {
chatItems.add(cItem)
}
return cItem
}
fun removeLiveDummy() {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.removeLast()
}
}
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
val markedRead = markItemsReadInCurrentChat(cInfo, range)
// update preview
@@ -276,7 +304,13 @@ class ChatModel(val controller: ChatController) {
while (i < chatItems.count()) {
val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
chatItems[i] = item.withStatus(CIStatus.RcvRead())
val newItem = item.withStatus(CIStatus.RcvRead())
chatItems[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
markedRead++
}
i += 1
@@ -396,8 +430,8 @@ interface SomeChat {
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
val voiceMessageAllowed: Boolean
val fullDeletionAllowed: Boolean
fun featureEnabled(feature: ChatFeature): Boolean
val timedMessagesTTL: Int?
val createdAt: Instant
val updatedAt: Instant
}
@@ -459,8 +493,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val ntfsEnabled get() = contact.ntfsEnabled
override val incognito get() = contact.incognito
override val voiceMessageAllowed get() = contact.voiceMessageAllowed
override val fullDeletionAllowed get() = contact.fullDeletionAllowed
override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL
override val createdAt get() = contact.createdAt
override val updatedAt get() = contact.updatedAt
override val displayName get() = contact.displayName
@@ -483,8 +517,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val ntfsEnabled get() = groupInfo.ntfsEnabled
override val incognito get() = groupInfo.incognito
override val voiceMessageAllowed get() = groupInfo.voiceMessageAllowed
override val fullDeletionAllowed get() = groupInfo.fullDeletionAllowed
override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL
override val createdAt get() = groupInfo.createdAt
override val updatedAt get() = groupInfo.updatedAt
override val displayName get() = groupInfo.displayName
@@ -507,8 +541,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val ntfsEnabled get() = contactRequest.ntfsEnabled
override val incognito get() = contactRequest.incognito
override val voiceMessageAllowed get() = contactRequest.voiceMessageAllowed
override val fullDeletionAllowed get() = contactRequest.fullDeletionAllowed
override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL
override val createdAt get() = contactRequest.createdAt
override val updatedAt get() = contactRequest.updatedAt
override val displayName get() = contactRequest.displayName
@@ -531,8 +565,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = contactConnection.incognito
override val incognito get() = contactConnection.incognito
override val voiceMessageAllowed get() = contactConnection.voiceMessageAllowed
override val fullDeletionAllowed get() = contactConnection.fullDeletionAllowed
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
override val createdAt get() = contactConnection.createdAt
override val updatedAt get() = contactConnection.updatedAt
override val displayName get() = contactConnection.displayName
@@ -545,6 +579,30 @@ sealed class ChatInfo: SomeChat, NamedChat {
ContactConnection(PendingContactConnection.getSampleData(status, viaContactUri))
}
}
@Serializable @SerialName("invalidJSON")
class InvalidJSON(val json: String): ChatInfo() {
override val chatType get() = ChatType.Direct
override val localDisplayName get() = invalidChatName
override val id get() = ""
override val apiId get() = 0L
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
override fun featureEnabled(feature: ChatFeature) = false
override val timedMessagesTTL: Int? get() = null
override val createdAt get() = Clock.System.now()
override val updatedAt get() = Clock.System.now()
override val displayName get() = invalidChatName
override val fullName get() = invalidChatName
override val image get() = null
override val localAlias get() = ""
companion object {
private val invalidChatName = generalGetString(R.string.invalid_chat)
}
}
}
@Serializable
@@ -568,19 +626,36 @@ data class Contact(
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = chatSettings.enableNtfs
override val incognito get() = contactConnIncognito
override val voiceMessageAllowed get() = mergedPreferences.voice.enabled.forUser
override val fullDeletionAllowed get() = mergedPreferences.fullDelete.enabled.forUser
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
}
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
override val displayName get() = localAlias.ifEmpty { profile.displayName }
override val fullName get() = profile.fullName
override val image get() = profile.image
override val localAlias get() = profile.localAlias
val verified get() = activeConn.connectionCode != null
val directContact: Boolean get() =
val directOrUsed: Boolean get() =
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
val contactConnIncognito =
activeConn.customUserProfileId != null
fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
}
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
}
companion object {
val sampleData = Contact(
contactId = 1,
@@ -612,13 +687,23 @@ class ContactSubStatus(
)
@Serializable
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null) {
data class Connection(
val connId: Long,
val connStatus: ConnStatus,
val connLevel: Int,
val viaGroupLink: Boolean,
val customUserProfileId: Long? = null,
val connectionCode: SecurityCode? = null
) {
val id: ChatId get() = ":$connId"
companion object {
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
}
}
@Serializable
data class SecurityCode(val securityCode: String, val verifiedAt: Instant)
@Serializable
data class Profile(
override val displayName: String,
@@ -691,8 +776,12 @@ data class GroupInfo (
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs
override val incognito get() = membership.memberIncognito
override val voiceMessageAllowed get() = fullGroupPreferences.voice.on
override val fullDeletionAllowed get() = fullGroupPreferences.fullDelete.on
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
ChatFeature.Voice -> fullGroupPreferences.voice.on
}
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
override val image get() = groupProfile.image
@@ -726,6 +815,7 @@ data class GroupInfo (
data class GroupProfile (
override val displayName: String,
override val fullName: String,
val description: String? = null,
override val image: String? = null,
override val localAlias: String = "",
val groupPreferences: GroupPreferences? = null
@@ -757,6 +847,7 @@ data class GroupMember (
val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
val fullName: String get() = memberProfile.fullName
val image: String? get() = memberProfile.image
val verified get() = activeConn?.connectionCode != null
val chatViewName: String
get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
@@ -937,8 +1028,8 @@ class UserContactRequest (
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
override val voiceMessageAllowed get() = false
override val fullDeletionAllowed get() = false
override fun featureEnabled(feature: ChatFeature) = false
override val timedMessagesTTL: Int? get() = null
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
override val image get() = profile.image
@@ -975,8 +1066,8 @@ class PendingContactConnection(
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = customUserProfileId != null
override val voiceMessageAllowed get() = false
override val fullDeletionAllowed get() = false
override fun featureEnabled(feature: ChatFeature) = false
override val timedMessagesTTL: Int? get() = null
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
override val displayName: String get() {
if (localAlias.isNotEmpty()) return localAlias
@@ -1074,7 +1165,7 @@ data class ChatItem (
}
}
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
val isRcvNew: Boolean get() = meta.isRcvNew
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
@@ -1117,10 +1208,13 @@ data class ChatItem (
is CIContent.SndConnEventContent -> showNtfDir
is CIContent.RcvChatFeature -> false
is CIContent.SndChatFeature -> showNtfDir
is CIContent.RcvChatPreference -> false
is CIContent.SndChatPreference -> showNtfDir
is CIContent.RcvGroupFeature -> false
is CIContent.SndGroupFeature -> showNtfDir
is CIContent.RcvChatFeatureRejected -> showNtfDir
is CIContent.RcvGroupFeatureRejected -> showNtfDir
is CIContent.InvalidJSON -> false
}
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
@@ -1136,11 +1230,12 @@ data class ChatItem (
file: CIFile? = null,
itemDeleted: Boolean = false,
itemEdited: Boolean = false,
itemTimed: CITimed? = null,
editable: Boolean = true
) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, null, editable),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem,
file = file
@@ -1195,7 +1290,7 @@ data class ChatItem (
)
fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled)
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null)
return ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
@@ -1206,7 +1301,8 @@ data class ChatItem (
}
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
const val TEMP_LIVE_CHAT_ITEM_ID = -2L
val deletedItemDummy: ChatItem
get() = ChatItem(
chatDir = CIDirection.DirectRcv(),
@@ -1216,14 +1312,46 @@ data class ChatItem (
itemText = generalGetString(R.string.deleted_description),
itemStatus = CIStatus.RcvRead(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemDeleted = false,
itemEdited = false,
itemTimed = null,
itemLive = false,
editable = false
),
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
quotedItem = null,
file = null
)
fun liveDummy(direct: Boolean): ChatItem = ChatItem(
chatDir = if (direct) CIDirection.DirectSnd() else CIDirection.GroupSnd(),
meta = CIMeta(
itemId = TEMP_LIVE_CHAT_ITEM_ID,
itemTs = Clock.System.now(),
itemText = "",
itemStatus = CIStatus.RcvRead(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemDeleted = false,
itemEdited = false,
itemTimed = null,
itemLive = true,
editable = false
),
content = CIContent.SndMsgContent(MsgContent.MCText("")),
quotedItem = null,
file = null
)
fun invalidJSON(json: String): ChatItem =
ChatItem(
chatDir = CIDirection.DirectSnd(),
meta = CIMeta.invalidJSON(),
content = CIContent.InvalidJSON(json),
quotedItem = null,
file = null
)
}
}
@@ -1249,16 +1377,33 @@ data class CIMeta (
val itemText: String,
val itemStatus: CIStatus,
val createdAt: Instant,
val updatedAt: Instant,
val itemDeleted: Boolean,
val itemEdited: Boolean,
val itemTimed: CITimed?,
val itemLive: Boolean?,
val editable: Boolean
) {
val timestampText: String get() = getTimestampText(itemTs)
val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now()
val isLive: Boolean get() = itemLive == true
val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
fun statusIcon(primaryColor: Color, metaColor: Color = HighOrLowlight): Pair<ImageVector, Color>? =
when (itemStatus) {
is CIStatus.SndSent -> Icons.Filled.Check to metaColor
is CIStatus.SndErrorAuth -> Icons.Filled.Close to Color.Red
is CIStatus.SndError -> Icons.Filled.WarningAmber to WarningYellow
is CIStatus.RcvNew -> Icons.Filled.Circle to primaryColor
else -> null
}
companion object {
fun getSample(
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true
itemDeleted: Boolean = false, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
): CIMeta =
CIMeta(
itemId = id,
@@ -1266,13 +1411,38 @@ data class CIMeta (
itemText = text,
itemStatus = status,
createdAt = ts,
updatedAt = ts,
itemDeleted = itemDeleted,
itemEdited = itemEdited,
itemTimed = itemTimed,
itemLive = itemLive,
editable = editable
)
fun invalidJSON(): CIMeta =
CIMeta(
// itemId can not be the same for different items, otherwise ChatView will crash
itemId = Random.nextLong(-1000000L, -1000L),
itemTs = Clock.System.now(),
itemText = "invalid JSON",
itemStatus = CIStatus.SndNew(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
itemDeleted = false,
itemEdited = false,
itemTimed = null,
itemLive = false,
editable = false
)
}
}
@Serializable
data class CITimed(
val ttl: Int,
val deleteAt: Instant?
)
fun getTimestampText(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
@@ -1320,12 +1490,15 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null }
override val text: String get() = when (this) {
is SndMsgContent -> msgContent.text
@@ -1341,13 +1514,34 @@ sealed class CIContent: ItemContent {
is SndGroupEventContent -> sndGroupEvent.text
is RcvConnEventContent -> rcvConnEvent.text
is SndConnEventContent -> sndConnEvent.text
is RcvChatFeature -> "${feature.text}: ${enabled.text}"
is SndChatFeature -> "${feature.text}: ${enabled.text}"
is RcvGroupFeature -> "${groupFeature.text}: ${preference.enable.text}"
is SndGroupFeature -> "${groupFeature.text}: ${preference.enable.text}"
is RcvChatFeature -> featureText(feature, enabled.text, param)
is SndChatFeature -> featureText(feature, enabled.text, param)
is RcvChatPreference -> preferenceText(feature, allowed, param)
is SndChatPreference -> preferenceText(feature, allowed, param)
is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param)
is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param)
is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
is InvalidJSON -> "invalid data"
}
companion object {
fun featureText(feature: Feature, enabled: String, param: Int?): String =
if (feature.hasParam) {
"${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
} else {
"${feature.text}: $enabled"
}
fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, TimedMessagesPreference.ttlText(param))
allowed != FeatureAllowed.NO ->
String.format(generalGetString(R.string.feature_offered_item), feature.text, TimedMessagesPreference.ttlText(param))
else ->
String.format(generalGetString(R.string.feature_cancelled_item), feature.text, TimedMessagesPreference.ttlText(param))
}
}
}
@Serializable
@@ -1438,14 +1632,8 @@ sealed class MsgContent {
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
val cmdString: String get() = when (this) {
is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}"
is MCVoice-> "json ${json.encodeToString(this)}"
is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json"
}
val cmdString: String get() =
if (this is MCUnknown) "json $json" else "json ${json.encodeToString(this)}"
}
@Serializable
@@ -33,8 +33,9 @@ import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.json.*
import java.util.Date
typealias ChatCtrl = Long
@@ -89,7 +90,7 @@ class AppPreferences(val context: Context) {
val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null)
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyTransferImagesInline = mkBoolPreference(SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE, false)
val privacyTransferImagesInline = mkBoolPreference(SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name)
val simplexLinkMode: SharedPreference<SimplexLinkMode> = SharedPreference(
@@ -115,12 +116,14 @@ class AppPreferences(val context: Context) {
val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout)
val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout)
val networkSMPPingInterval = mkLongPreference(SHARED_PREFS_NETWORK_SMP_PING_INTERVAL, NetCfg.defaults.smpPingInterval)
val networkSMPPingCount = mkIntPreference(SHARED_PREFS_NETWORK_SMP_PING_COUNT, NetCfg.defaults.smpPingCount)
val networkEnableKeepAlive = mkBoolPreference(SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE, NetCfg.defaults.enableKeepAlive)
val networkTCPKeepIdle = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_IDLE, KeepAliveOpts.defaults.keepIdle)
val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl)
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name)
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false)
@@ -131,6 +134,8 @@ class AppPreferences(val context: Context) {
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
get = fun() = sharedPreferences.getInt(prefName, default),
@@ -208,12 +213,14 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout"
private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout"
private const val SHARED_PREFS_NETWORK_SMP_PING_INTERVAL = "NetworkSMPPingInterval"
private const val SHARED_PREFS_NETWORK_SMP_PING_COUNT = "NetworkSMPPingCount"
private const val SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE = "NetworkEnableKeepAlive"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_IDLE = "NetworkTCPKeepIdle"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
private const val SHARED_PREFS_INCOGNITO = "Incognito"
private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab"
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
@@ -221,6 +228,7 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor"
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
}
}
@@ -262,10 +270,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
chatModel.appOpenUrl.value?.let {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(it, chatModel)
}
startReceiver()
Log.d(TAG, "startChat: started")
} else {
@@ -404,19 +408,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
suspend fun apiGetChats(): List<Chat> {
val r = sendCmd(CC.ApiGetChats())
if (r is CR.ApiChats ) return r.chats
throw Exception("failed getting the list of chats: ${r.responseType} ${r.details}")
if (r is CR.ApiChats) return r.chats
Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chats_title), generalGetString(R.string.contact_developers))
return emptyList()
}
suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
val r = sendCmd(CC.ApiGetChat(type, id, pagination, search))
if (r is CR.ApiChat ) return r.chat
if (r is CR.ApiChat) return r.chat
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chat_title), generalGetString(R.string.contact_developers))
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc)
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live)
val r = sendCmd(cmd)
return when (r) {
is CR.NewChatItem -> r.chatItem
@@ -429,8 +436,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc))
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? {
val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live))
if (r is CR.ChatItemUpdated) return r.chatItem
Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
return null
@@ -553,6 +560,34 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun apiGetContactCode(contactId: Long): Pair<Contact, String> {
val r = sendCmd(CC.APIGetContactCode(contactId))
if (r is CR.ContactCode) return r.contact to r.connectionCode
throw Exception("failed to get contact code: ${r.responseType} ${r.details}")
}
suspend fun apiGetGroupMemberCode(groupId: Long, groupMemberId: Long): Pair<GroupMember, String> {
val r = sendCmd(CC.APIGetGroupMemberCode(groupId, groupMemberId))
if (r is CR.GroupMemberCode) return r.member to r.connectionCode
throw Exception("failed to get group member code: ${r.responseType} ${r.details}")
}
suspend fun apiVerifyContact(contactId: Long, connectionCode: String?): Pair<Boolean, String>? {
return when (val r = sendCmd(CC.APIVerifyContact(contactId, connectionCode))) {
is CR.ConnectionVerified -> r.verified to r.expectedCode
else -> null
}
}
suspend fun apiVerifyGroupMember(groupId: Long, groupMemberId: Long, connectionCode: String?): Pair<Boolean, String>? {
return when (val r = sendCmd(CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) {
is CR.ConnectionVerified -> r.verified to r.expectedCode
else -> null
}
}
suspend fun apiAddContact(): String? {
val r = sendCmd(CC.AddContact())
return when (r) {
@@ -952,6 +987,24 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun allowFeatureToContact(contact: Contact, feature: ChatFeature, param: Int? = null) {
val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param)
val toContact = apiSetContactPrefs(contact.contactId, prefs)
if (toContact != null) {
chatModel.updateContact(toContact)
}
}
suspend fun apiGetVersion(): CoreVersionInfo? {
val r = sendCmd(CC.ShowVersion())
return if (r is CR.VersionInfo) {
r.versionInfo
} else {
Log.e(TAG, "apiGetVersion bad response: ${r.responseType} ${r.details}")
null
}
}
private fun networkErrorAlert(r: CR): Boolean {
return when {
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
@@ -982,7 +1035,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
AlertManager.shared.showAlertMsg(title, errMsg)
}
fun processReceivedMsg(r: CR) {
suspend fun processReceivedMsg(r: CR) {
lastMsgReceivedTimestamp = System.currentTimeMillis()
chatModel.terminalItems.add(TerminalItem.resp(r))
when (r) {
@@ -993,7 +1046,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.removeChat(r.connection.id)
}
is CR.ContactConnected -> {
if (r.contact.directContact) {
if (r.contact.directOrUsed) {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
@@ -1002,7 +1055,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
is CR.ContactConnecting -> {
if (r.contact.directContact) {
if (r.contact.directOrUsed) {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
@@ -1216,7 +1269,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
private suspend fun chatItemSimpleUpdate(aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
if (chatModel.upsertChatItem(cInfo, cItem)) {
@@ -1249,6 +1302,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
fun showBackgroundServiceNoticeIfNeeded() {
val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!)
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
// Nothing to do if mode is OFF. Can be selected on on-boarding stage
if (mode == NotificationsMode.OFF) return
if (!appPrefs.backgroundServiceNoticeShown.get()) {
// the branch for the new users who have never seen service notice
if (!mode.requiresIgnoringBattery || isIgnoringBatteryOptimizations(appContext)) {
@@ -1441,6 +1497,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get()
val tcpTimeout = appPrefs.networkTCPTimeout.get()
val smpPingInterval = appPrefs.networkSMPPingInterval.get()
val smpPingCount = appPrefs.networkSMPPingCount.get()
val enableKeepAlive = appPrefs.networkEnableKeepAlive.get()
val tcpKeepAlive = if (enableKeepAlive) {
val keepIdle = appPrefs.networkTCPKeepIdle.get()
@@ -1457,7 +1514,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
tcpConnectTimeout = tcpConnectTimeout,
tcpTimeout = tcpTimeout,
tcpKeepAlive = tcpKeepAlive,
smpPingInterval = smpPingInterval
smpPingInterval = smpPingInterval,
smpPingCount = smpPingCount
)
}
@@ -1468,6 +1526,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout)
appPrefs.networkTCPTimeout.set(cfg.tcpTimeout)
appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval)
appPrefs.networkSMPPingCount.set(cfg.smpPingCount)
if (cfg.tcpKeepAlive != null) {
appPrefs.networkEnableKeepAlive.set(true)
appPrefs.networkTCPKeepIdle.set(cfg.tcpKeepAlive.keepIdle)
@@ -1507,8 +1566,8 @@ sealed class CC {
class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class NewGroup(val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
@@ -1533,6 +1592,10 @@ sealed class CC {
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
class APISwitchContact(val contactId: Long): CC()
class APISwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC()
class APIGetContactCode(val contactId: Long): CC()
class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC()
class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC()
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
@@ -1559,6 +1622,7 @@ sealed class CC {
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val inline: Boolean): CC()
class ShowVersion(): CC()
val cmdString: String get() = when (this) {
is Console -> cmd
@@ -1574,8 +1638,8 @@ sealed class CC {
is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
is ApiGetChats -> "/_get chats pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is NewGroup -> "/_group ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
@@ -1600,6 +1664,10 @@ sealed class CC {
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
is APISwitchContact -> "/_switch @$contactId"
is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId"
is APIGetContactCode -> "/_get code @$contactId"
is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId"
is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else ""
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
@@ -1626,6 +1694,7 @@ sealed class CC {
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile -> "/freceive $fileId inline=${onOff(inline)}"
is ShowVersion -> "/version"
}
val cmdType: String get() = when (this) {
@@ -1668,6 +1737,10 @@ sealed class CC {
is APIGroupMemberInfo -> "apiGroupMemberInfo"
is APISwitchContact -> "apiSwitchContact"
is APISwitchGroupMember -> "apiSwitchGroupMember"
is APIGetContactCode -> "apiGetContactCode"
is APIGetGroupMemberCode -> "apiGetGroupMemberCode"
is APIVerifyContact -> "apiVerifyContact"
is APIVerifyGroupMember -> "apiVerifyGroupMember"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
@@ -1694,6 +1767,7 @@ sealed class CC {
is ApiChatRead -> "apiChatRead"
is ApiChatUnread -> "apiChatUnread"
is ReceiveFile -> "receiveFile"
is ShowVersion -> "showVersion"
}
class ItemRange(val from: Long, val to: Long)
@@ -1896,7 +1970,9 @@ data class NetCfg(
val tcpConnectTimeout: Long, // microseconds
val tcpTimeout: Long, // microseconds
val tcpKeepAlive: KeepAliveOpts?,
val smpPingInterval: Long // microseconds
val smpPingInterval: Long, // microseconds
val smpPingCount: Int,
val logTLSErrors: Boolean = false
) {
val useSocksProxy: Boolean get() = socksProxy != null
val enableKeepAlive: Boolean get() = tcpKeepAlive != null
@@ -1908,7 +1984,8 @@ data class NetCfg(
tcpConnectTimeout = 10_000_000,
tcpTimeout = 7_000_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 600_000_000
smpPingInterval = 1200_000_000,
smpPingCount = 3
)
val proxyDefaults: NetCfg =
@@ -1917,7 +1994,8 @@ data class NetCfg(
tcpConnectTimeout = 20_000_000,
tcpTimeout = 15_000_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 600_000_000
smpPingInterval = 1200_000_000,
smpPingCount = 3
)
}
@@ -1968,62 +2046,150 @@ data class ChatSettings(
@Serializable
data class FullChatPreferences(
val fullDelete: ChatPreference,
val voice: ChatPreference,
val timedMessages: TimedMessagesPreference,
val fullDelete: SimpleChatPreference,
val voice: SimpleChatPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice)
fun toPreferences(): ChatPreferences = ChatPreferences(timedMessages = timedMessages, fullDelete = fullDelete, voice = voice)
companion object {
val sampleData = FullChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
val sampleData = FullChatPreferences(
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
voice = SimpleChatPreference(allow = FeatureAllowed.YES)
)
}
}
@Serializable
data class ChatPreferences(
val fullDelete: ChatPreference? = null,
val voice: ChatPreference? = null,
val timedMessages: TimedMessagesPreference?,
val fullDelete: SimpleChatPreference?,
val voice: SimpleChatPreference?,
) {
fun setAllowed(feature: ChatFeature, allowed: FeatureAllowed = FeatureAllowed.YES, param: Int? = null): ChatPreferences =
when (feature) {
ChatFeature.TimedMessages -> this.copy(timedMessages = TimedMessagesPreference(allow = allowed, ttl = param ?: this.timedMessages?.ttl))
ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed))
ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed))
}
companion object {
val sampleData = ChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
val sampleData = ChatPreferences(
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
voice = SimpleChatPreference(allow = FeatureAllowed.YES)
)
}
}
interface ChatPreference {
val allow: FeatureAllowed
}
@Serializable
data class SimpleChatPreference(
override val allow: FeatureAllowed
): ChatPreference
@Serializable
data class TimedMessagesPreference(
override val allow: FeatureAllowed,
val ttl: Int? = null
): ChatPreference {
companion object {
val ttlValues: List<Int?>
get() = listOf(30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400)
fun ttlText(ttl: Int?): String {
ttl ?: return generalGetString(R.string.feature_off)
if (ttl == 0) return String.format(generalGetString(R.string.ttl_sec), 0)
val (m_, s) = divMod(ttl, 60)
val (h_, m) = divMod(m_, 60)
val (d_, h) = divMod(h_, 24)
val (mm, d) = divMod(d_, 30)
return maybe(mm, if (mm == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), mm)) +
maybe(d, if (d == 1) String.format(generalGetString(R.string.ttl_day), 1) else if (d == 7) String.format(generalGetString(R.string.ttl_week), 1) else if (d == 14) String.format(generalGetString(R.string.ttl_weeks), 2) else String.format(generalGetString(R.string.ttl_days), d)) +
maybe(h, if (h == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), h)) +
maybe(m, String.format(generalGetString(R.string.ttl_min), m)) +
maybe(s, String.format(generalGetString(R.string.ttl_sec), s))
}
fun shortTtlText(ttl: Int?): String {
ttl ?: return generalGetString(R.string.feature_off)
val m = ttl / 60
if (m == 0) {
return String.format(generalGetString(R.string.ttl_s), ttl)
}
val h = m / 60
if (h == 0) {
return String.format(generalGetString(R.string.ttl_m), m)
}
val d = h / 24
if (d == 0) {
return String.format(generalGetString(R.string.ttl_h), h)
}
val mm = d / 30
if (mm > 0) {
return String.format(generalGetString(R.string.ttl_mth), mm)
}
val w = d / 7
return if (w == 0 || d % 7 != 0) String.format(generalGetString(R.string.ttl_d), d) else String.format(generalGetString(R.string.ttl_w), w)
}
fun divMod(n: Int, d: Int): Pair<Int, Int> =
n / d to n % d
fun maybe(n: Int, s: String): String =
if (n == 0) "" else s
}
}
@Serializable
data class ChatPreference(
val allow: FeatureAllowed
)
@Serializable
data class ContactUserPreferences(
val timedMessages: ContactUserPreferenceTimed,
val fullDelete: ContactUserPreference,
val voice: ContactUserPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
timedMessages = timedMessages.userPreference.pref,
fullDelete = fullDelete.userPreference.pref,
voice = voice.userPreference.pref
)
companion object {
val sampleData = ContactUserPreferences(
timedMessages = ContactUserPreferenceTimed(
enabled = FeatureEnabled(forUser = false, forContact = false),
userPreference = ContactUserPrefTimed.User(preference = TimedMessagesPreference(allow = FeatureAllowed.NO)),
contactPreference = TimedMessagesPreference(allow = FeatureAllowed.NO)
),
fullDelete = ContactUserPreference(
enabled = FeatureEnabled(forUser = false, forContact = false),
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)),
contactPreference = ChatPreference(allow = FeatureAllowed.NO)
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.NO)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.NO)
),
voice = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.YES)),
contactPreference = ChatPreference(allow = FeatureAllowed.YES)
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
)
)
}
}
@Serializable
data class ContactUserPreference(
data class ContactUserPreference (
val enabled: FeatureEnabled,
val userPreference: ContactUserPref,
val contactPreference: ChatPreference,
val contactPreference: SimpleChatPreference,
)
@Serializable
data class ContactUserPreferenceTimed (
val enabled: FeatureEnabled,
val userPreference: ContactUserPrefTimed,
val contactPreference: TimedMessagesPreference,
)
@Serializable
@@ -2043,10 +2209,10 @@ data class FeatureEnabled(
get() = if (forUser) SimplexGreen else if (forContact) WarningYellow else HighOrLowlight
companion object {
fun enabled(user: ChatPreference, contact: ChatPreference): FeatureEnabled =
fun enabled(asymmetric: Boolean, user: ChatPreference, contact: ChatPreference): FeatureEnabled =
when {
user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = true)
user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = true, forContact = false)
user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = asymmetric)
user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = asymmetric, forContact = false)
contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
user.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
else -> FeatureEnabled(forUser = true, forContact = true)
@@ -2056,49 +2222,95 @@ data class FeatureEnabled(
@Serializable
sealed class ContactUserPref {
abstract val pref: ChatPreference
abstract val pref: SimpleChatPreference
// contact override is set
@Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() {
@Serializable @SerialName("contact") data class Contact(val preference: SimpleChatPreference): ContactUserPref() {
override val pref get() = preference
}
// global user default is used
@Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() {
@Serializable @SerialName("user") data class User(val preference: SimpleChatPreference): ContactUserPref() {
override val pref get() = preference
}
val contactOverride: SimpleChatPreference?
get() = when(this) {
is Contact -> pref
is User -> null
}
}
@Serializable
sealed class ContactUserPrefTimed {
abstract val pref: TimedMessagesPreference
// contact override is set
@Serializable @SerialName("contact") data class Contact(val preference: TimedMessagesPreference): ContactUserPrefTimed() {
override val pref get() = preference
}
// global user default is used
@Serializable @SerialName("user") data class User(val preference: TimedMessagesPreference): ContactUserPrefTimed() {
override val pref get() = preference
}
val contactOverride: TimedMessagesPreference?
get() = when(this) {
is Contact -> pref
is User -> null
}
}
interface Feature {
// val icon: ImageVector
val text: String
val iconFilled: ImageVector
val hasParam: Boolean
}
@Serializable
enum class ChatFeature: Feature {
@SerialName("timedMessages") TimedMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("voice") Voice;
val asymmetric: Boolean get() = when (this) {
TimedMessages -> false
else -> true
}
override val hasParam: Boolean get() = when(this) {
TimedMessages -> true
else -> false
}
override val text: String
get() = when(this) {
TimedMessages -> generalGetString(R.string.timed_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Voice -> generalGetString(R.string.voice_messages)
}
val icon: ImageVector
get() = when(this) {
TimedMessages -> Icons.Outlined.Timer
FullDelete -> Icons.Outlined.DeleteForever
Voice -> Icons.Outlined.KeyboardVoice
}
override val iconFilled: ImageVector
get() = when(this) {
TimedMessages -> Icons.Filled.Timer
FullDelete -> Icons.Filled.DeleteForever
Voice -> Icons.Filled.KeyboardVoice
}
fun allowDescription(allowed: FeatureAllowed): String =
when (this) {
TimedMessages -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_disappearing_messages)
FeatureAllowed.YES -> generalGetString(R.string.allow_disappearing_messages_only_if)
FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_disappearing_messages)
}
FullDelete -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_irreversibly_delete)
FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
@@ -2113,6 +2325,12 @@ enum class ChatFeature: Feature {
fun enabledDescription(enabled: FeatureEnabled): String =
when (this) {
TimedMessages -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_disappearing)
enabled.forUser -> generalGetString(R.string.only_you_can_send_disappearing)
enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_disappearing)
else -> generalGetString(R.string.disappearing_prohibited_in_this_chat)
}
FullDelete -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contacts_can_delete)
enabled.forUser -> generalGetString(R.string.only_you_can_delete_messages)
@@ -2130,12 +2348,19 @@ enum class ChatFeature: Feature {
@Serializable
enum class GroupFeature: Feature {
@SerialName("timedMessages") TimedMessages,
@SerialName("directMessages") DirectMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("voice") Voice;
override val hasParam: Boolean get() = when(this) {
TimedMessages -> true
else -> false
}
override val text: String
get() = when(this) {
TimedMessages -> generalGetString(R.string.timed_messages)
DirectMessages -> generalGetString(R.string.direct_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Voice -> generalGetString(R.string.voice_messages)
@@ -2143,6 +2368,7 @@ enum class GroupFeature: Feature {
val icon: ImageVector
get() = when(this) {
TimedMessages -> Icons.Outlined.Timer
DirectMessages -> Icons.Outlined.SwapHorizontalCircle
FullDelete -> Icons.Outlined.DeleteForever
Voice -> Icons.Outlined.KeyboardVoice
@@ -2150,6 +2376,7 @@ enum class GroupFeature: Feature {
override val iconFilled: ImageVector
get() = when(this) {
TimedMessages -> Icons.Filled.Timer
DirectMessages -> Icons.Filled.SwapHorizontalCircle
FullDelete -> Icons.Filled.DeleteForever
Voice -> Icons.Filled.KeyboardVoice
@@ -2158,6 +2385,10 @@ enum class GroupFeature: Feature {
fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
if (canEdit) {
when(this) {
TimedMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_disappearing)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_disappearing)
}
DirectMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_direct_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_direct_messages)
@@ -2173,6 +2404,10 @@ enum class GroupFeature: Feature {
}
} else {
when(this) {
TimedMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_disappearing)
GroupFeatureEnabled.OFF -> generalGetString(R.string.disappearing_messages_are_prohibited)
}
DirectMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_dms)
GroupFeatureEnabled.OFF -> generalGetString(R.string.direct_messages_are_prohibited_in_chat)
@@ -2218,22 +2453,31 @@ sealed class ContactFeatureAllowed {
@Serializable
data class ContactFeaturesAllowed(
val timedMessagesAllowed: Boolean,
val timedMessagesTTL: Int?,
val fullDelete: ContactFeatureAllowed,
val voice: ContactFeatureAllowed
) {
companion object {
val sampleData = ContactFeaturesAllowed(
timedMessagesAllowed = false,
timedMessagesTTL = null,
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES)
)
}
}
fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed =
ContactFeaturesAllowed(
fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed {
val pref = contactUserPreferences.timedMessages.userPreference
val allow = pref.contactOverride?.allow
return ContactFeaturesAllowed(
timedMessagesAllowed = allow == FeatureAllowed.YES || allow == FeatureAllowed.ALWAYS,
timedMessagesTTL = pref.pref.ttl,
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
)
}
fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed =
when (val pref = contactUserPreference.userPreference) {
@@ -2247,16 +2491,17 @@ fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference
fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences =
ChatPreferences(
timedMessages = TimedMessagesPreference(if (contactFeaturesAllowed.timedMessagesAllowed) FeatureAllowed.YES else FeatureAllowed.NO, contactFeaturesAllowed.timedMessagesTTL),
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
)
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): ChatPreference? =
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): SimpleChatPreference? =
when(contactFeatureAllowed) {
is ContactFeatureAllowed.UserDefault -> null
is ContactFeatureAllowed.Always -> ChatPreference(allow = FeatureAllowed.ALWAYS)
is ContactFeatureAllowed.Yes -> ChatPreference(allow = FeatureAllowed.YES)
is ContactFeatureAllowed.No -> ChatPreference(allow = FeatureAllowed.NO)
is ContactFeatureAllowed.Always -> SimpleChatPreference(allow = FeatureAllowed.ALWAYS)
is ContactFeatureAllowed.Yes -> SimpleChatPreference(allow = FeatureAllowed.YES)
is ContactFeatureAllowed.No -> SimpleChatPreference(allow = FeatureAllowed.NO)
}
@Serializable
@@ -2275,26 +2520,38 @@ enum class FeatureAllowed {
@Serializable
data class FullGroupPreferences(
val timedMessages: TimedMessagesGroupPreference,
val directMessages: GroupPreference,
val fullDelete: GroupPreference,
val voice: GroupPreference
) {
fun toGroupPreferences(): GroupPreferences =
GroupPreferences(directMessages = directMessages, fullDelete = fullDelete, voice = voice)
GroupPreferences(timedMessages = timedMessages, directMessages = directMessages, fullDelete = fullDelete, voice = voice)
companion object {
val sampleData = FullGroupPreferences(directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON))
val sampleData = FullGroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
voice = GroupPreference(GroupFeatureEnabled.ON)
)
}
}
@Serializable
data class GroupPreferences(
val timedMessages: TimedMessagesGroupPreference?,
val directMessages: GroupPreference?,
val fullDelete: GroupPreference?,
val voice: GroupPreference?
) {
companion object {
val sampleData = GroupPreferences(directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON))
val sampleData = GroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
voice = GroupPreference(GroupFeatureEnabled.ON)
)
}
}
@@ -2305,6 +2562,14 @@ data class GroupPreference(
val on: Boolean get() = enable == GroupFeatureEnabled.ON
}
@Serializable
data class TimedMessagesGroupPreference(
val enable: GroupFeatureEnabled,
val ttl: Int? = null
) {
val on: Boolean get() = enable == GroupFeatureEnabled.ON
}
@Serializable
enum class GroupFeatureEnabled {
@SerialName("on") ON,
@@ -2338,8 +2603,29 @@ class APIResponse(val resp: CR, val corr: String? = null) {
try {
Log.d(TAG, e.localizedMessage ?: "")
val data = json.parseToJsonElement(str).jsonObject
val resp = data["resp"]!!.jsonObject
val type = resp["type"]?.jsonPrimitive?.content ?: "invalid"
try {
if (type == "apiChats") {
val chats: List<Chat> = resp["chats"]!!.jsonArray.map {
parseChatData(it)
}
return APIResponse(
resp = CR.ApiChats(chats),
corr = data["corr"]?.toString()
)
} else if (type == "apiChat") {
val chat = parseChatData(resp["chat"]!!)
return APIResponse(
resp = CR.ApiChat(chat),
corr = data["corr"]?.toString()
)
}
} catch (e: Exception) {
Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString())
}
APIResponse(
resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)),
resp = CR.Response(type, json.encodeToString(data)),
corr = data["corr"]?.toString()
)
} catch(e: Exception) {
@@ -2350,6 +2636,19 @@ class APIResponse(val resp: CR, val corr: String? = null) {
}
}
private fun parseChatData(chat: JsonElement): Chat {
val chatInfo: ChatInfo = decodeObject(ChatInfo.serializer(), chat.jsonObject["chatInfo"])
?: ChatInfo.InvalidJSON(json.encodeToString(chat.jsonObject["chatInfo"]))
val chatStats = decodeObject(Chat.ChatStats.serializer(), chat.jsonObject["chatStats"])!!
val chatItems: List<ChatItem> = chat.jsonObject["chatItems"]!!.jsonArray.map {
decodeObject(ChatItem.serializer(), it) ?: ChatItem.invalidJSON(json.encodeToString(it))
}
return Chat(chatInfo, chatItems, chatStats)
}
private fun <T> decodeObject(deserializer: DeserializationStrategy<T>, obj: JsonElement?): T? =
runCatching { json.decodeFromJsonElement(deserializer, obj!!) }.getOrNull()
// ChatResponse
@Serializable
sealed class CR {
@@ -2365,6 +2664,9 @@ sealed class CR {
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
@Serializable @SerialName("contactCode") class ContactCode(val contact: Contact, val connectionCode: String): CR()
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
@@ -2444,6 +2746,7 @@ sealed class CR {
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
@Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR()
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo): CR()
@Serializable @SerialName("cmdOk") class CmdOk: CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR()
@@ -2463,6 +2766,9 @@ sealed class CR {
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
is GroupMemberInfo -> "groupMemberInfo"
is ContactCode -> "contactCode"
is GroupMemberCode -> "groupMemberCode"
is ConnectionVerified -> "connectionVerified"
is Invitation -> "invitation"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
@@ -2539,6 +2845,7 @@ sealed class CR {
is CallEnded -> "callEnded"
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted"
is VersionInfo -> "versionInfo"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
@@ -2559,6 +2866,9 @@ sealed class CR {
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}"
is ContactCode -> "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode"
is GroupMemberCode -> "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode"
is ConnectionVerified -> "verified: $verified\nconnectionCode: $expectedCode"
is Invitation -> connReqInvitation
is SentConfirmation -> noDetails()
is SentInvitation -> noDetails()
@@ -2635,6 +2945,7 @@ sealed class CR {
is CallEnded -> "contact: ${contact.id}"
is NewContactConnection -> json.encodeToString(connection)
is ContactConnectionDeleted -> json.encodeToString(connection)
is VersionInfo -> json.encodeToString(versionInfo)
is CmdOk -> noDetails()
is ChatCmdError -> chatError.string
is ChatRespError -> chatError.string
@@ -2692,6 +3003,13 @@ class AutoAccept(val acceptIncognito: Boolean, val autoReply: MsgContent?) {
}
}
@Serializable
data class CoreVersionInfo(
val version: String,
val buildTimestamp: String,
val simplexmqVersion: String,
val simplexmqCommit: String
)
@Serializable
sealed class ChatError {
@@ -135,7 +135,21 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
allowVoiceToContact = {},
sendMessage = sendCommand,
sendLiveMessage = null,
updateLiveMessage = null,
onMessageChange = ::onMessageChange,
textStyle = textStyle
)
}
},
modifier = Modifier.navigationBarsWithImePadding()
@@ -160,7 +174,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
Text(
@@ -111,9 +111,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
SimplexService.start(chatModel.controller.appContext)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
}
}
@@ -38,6 +38,7 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
@Composable
fun ChatInfoView(
@@ -46,6 +47,7 @@ fun ChatInfoView(
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
connectionCode: String?,
close: () -> Unit,
) {
BackHandler(onBack = close)
@@ -58,6 +60,7 @@ fun ChatInfoView(
connStats,
customUserProfile,
localAlias,
connectionCode,
developerTools,
onLocalAliasChanged = {
setContactAlias(chat.chatInfo.apiId, it, chatModel)
@@ -74,6 +77,31 @@ fun ChatInfoView(
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
switchContactAddress = {
showSwitchContactAddressAlert(chatModel, contact.contactId)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
VerifyCodeView(
ct.displayName,
connectionCode,
ct.verified,
verify = { code ->
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.updateContact(
ct.copy(
activeConn = ct.activeConn.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
r
}
},
close,
)
}
}
}
)
}
@@ -123,12 +151,14 @@ fun ChatInfoLayout(
connStats: ConnectionStats?,
customUserProfile: Profile?,
localAlias: String,
connectionCode: String?,
developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit,
openPreferences: () -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit,
switchContactAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
Column(
Modifier
@@ -154,6 +184,10 @@ fun ChatInfoLayout(
SectionSpacer()
SectionView {
if (connectionCode != null) {
VerifyCodeButton(contact.verified, verifyClicked)
SectionDivider()
}
ContactPreferencesButton(openPreferences)
}
@@ -208,13 +242,17 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
if (contact.verified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
}
Text(
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
@@ -276,7 +314,7 @@ fun LocalAliasEditor(
}
@Composable
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -308,7 +346,7 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
}
@Composable
fun ServerImage(networkStatus: Chat.NetworkStatus) {
private fun ServerImage(networkStatus: Chat.NetworkStatus) {
Box(Modifier.size(18.dp)) {
when (networkStatus) {
is Chat.NetworkStatus.Connected ->
@@ -339,6 +377,16 @@ fun SwitchAddressButton(onClick: () -> Unit) {
}
}
@Composable
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
SettingsActionItem(
if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
click = onClick,
iconColor = HighOrLowlight,
)
}
@Composable
private fun ContactPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -360,7 +408,7 @@ fun ClearChatButton(onClick: () -> Unit) {
}
@Composable
fun DeleteContactButton(onClick: () -> Unit) {
private fun DeleteContactButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.button_delete_contact),
@@ -403,6 +451,7 @@ fun PreviewChatInfoLayout() {
),
Contact.sampleData,
localAlias = "",
connectionCode = "123",
developerTools = false,
connStats = null,
onLocalAliasChanged = {},
@@ -411,6 +460,7 @@ fun PreviewChatInfoLayout() {
deleteContact = {},
clearChat = {},
switchContactAddress = {},
verifyClicked = {},
)
}
}
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
@@ -12,8 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
@@ -77,16 +77,23 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
}
}
launch {
// .toList() is important for making observation working
snapshotFlow { chatModel.chats.toList() }
.distinctUntilChanged()
.collect { chats ->
chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }.let {
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
if (it?.chatInfo != activeChat.value?.chatInfo) {
activeChat.value = it
}}
snapshotFlow {
/**
* It's possible that in some cases concurrent modification can happen on [ChatModel.chats] list.
* In this case only error log will be printed here (no crash).
* TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
* */
try {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
} catch (e: ConcurrentModificationException) {
Log.e(TAG, e.stackTraceToString())
null
}
}
.distinctUntilChanged()
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
.collect { activeChat.value = it }
}
}
val view = LocalView.current
@@ -131,16 +138,17 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
withApi {
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
ModalManager.shared.showModalCloseable(true) { close ->
val contact = remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
contact.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
}
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, close)
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
}
}
}
@@ -149,8 +157,21 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
hideKeyboard(view)
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
val (_, code) = if (member.memberActive) {
try {
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
member to null
}
} else {
member to null
}
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
}
}
}
},
@@ -205,6 +226,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.callManager.acceptIncomingCall(invitation = invitation)
}
},
acceptFeature = { contact, feature, param ->
withApi {
chatModel.controller.allowFeatureToContact(contact, feature, param)
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withApi {
@@ -261,6 +287,7 @@ fun ChatLayout(
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
@@ -301,7 +328,7 @@ fun ChatLayout(
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
)
}
}
@@ -424,10 +451,15 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
Row(verticalAlignment = Alignment.CenterVertically) {
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
ContactVerifiedShield()
}
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
Text(
cInfo.fullName,
@@ -438,6 +470,11 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
}
}
@Composable
private fun ContactVerifiedShield() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
}
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
val CIListStateSaver = run {
@@ -466,6 +503,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
@@ -491,7 +529,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
val scrollToItem: (Long) -> Unit = { itemId: Long ->
val index = reversedChatItems.indexOfFirst { it.id == itemId }
@@ -530,7 +568,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
}
@@ -571,11 +609,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
} else {
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
}
} else { // direct message
@@ -586,7 +624,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
}
}
@@ -617,17 +655,21 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false to chatId
}
val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
/*
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
* When the first visible item (from bottom) is fully visible we can autoscroll to 0 item
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
* */
LaunchedEffect(Unit) {
snapshotFlow { chatItems.lastOrNull()?.id }
.distinctUntilChanged()
.filter { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 }
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect {
listState.animateScrollToItem(0)
if (listState.firstVisibleItemIndex == 0) {
listState.animateScrollToItem(0)
} else {
listState.animateScrollBy(scrollDistance)
}
}
}
}
@@ -984,6 +1026,7 @@ fun PreviewChatLayout() {
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
@@ -1042,6 +1085,7 @@ fun PreviewGroupChatLayout() {
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat
import ComposeVoiceView
import ComposeFileView
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.pm.PackageManager
import android.graphics.Bitmap
@@ -19,8 +20,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
@@ -41,6 +41,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
@@ -62,16 +63,26 @@ sealed class ComposeContextItem {
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
}
@Serializable
data class LiveMessage(
val chatItem: ChatItem,
val typedMsg: String,
val sentMsg: String,
val sent: Boolean
)
@Serializable
data class ComposeState(
val message: String = "",
val liveMessage: LiveMessage? = null,
val preview: ComposePreview = ComposePreview.NoPreview,
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
val inProgress: Boolean = false,
val useLinkPreviews: Boolean
) {
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
editingItem.content.text,
liveMessage,
chatItemPreview(editingItem),
ComposeContextItem.EditingItem(editingItem),
useLinkPreviews = useLinkPreviews
@@ -89,10 +100,13 @@ data class ComposeState(
is ComposePreview.ImagePreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty()
else -> message.isNotEmpty() || liveMessage != null
}
hasContent && !inProgress
}
val endLiveDisabled: Boolean
get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
val linkPreviewAllowed: Boolean
get() =
when (preview) {
@@ -108,6 +122,16 @@ data class ComposeState(
else -> null
}
val attachmentDisabled: Boolean
get() {
if (editing || liveMessage != null) return true
return when (preview) {
ComposePreview.NoPreview -> false
is ComposePreview.CLinkPreview -> false
else -> true
}
}
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
save = { json.encodeToString(serializer(), it.value) },
@@ -118,6 +142,15 @@ data class ComposeState(
}
}
sealed class RecordingState {
object NotStarted: RecordingState()
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
class Finished(val filePath: String, val durationMs: Int): RecordingState()
val filePathNullable: String?
get() = (this as? Started)?.filePath
}
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
return when (val mc = chatItem.content.msgContent) {
is MsgContent.MCText -> ComposePreview.NoPreview
@@ -233,6 +266,7 @@ fun ComposeView(
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
@@ -310,128 +344,160 @@ fun ComposeView(
cancelledLinks.clear()
}
fun checkLinkPreview(): MsgContent {
val cs = composeState.value
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(cs.message)
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(cs.message, preview = lp)
} else {
MsgContent.MCText(cs.message)
}
}
else -> MsgContent.MCText(cs.message)
fun clearState(live: Boolean = false) {
if (live) {
composeState.value = composeState.value.copy(inProgress = false)
} else {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
resetLinkPreview()
}
}
fun updateMsgContent(msgContent: MsgContent): MsgContent {
val cs = composeState.value
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
}
fun clearState() {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
recState.value = RecordingState.NotStarted
textStyle.value = smallFont
chosenContent.value = emptyList()
chosenAudio.value = null
chosenFile.value = null
linkUrl.value = null
prevLinkUrl.value = null
pendingLinkUrl.value = null
cancelledLinks.clear()
chatModel.removeLiveDummy()
}
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = file,
quotedItemId = quoted,
mc = mc,
live = live
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem?.chatItem
}
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
val cInfo = chat.chatInfo
val cs = composeState.value
var sent: ChatItem?
val msgText = text ?: cs.message
fun sending() {
composeState.value = composeState.value.copy(inProgress = true)
}
fun checkLinkPreview(): MsgContent {
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val url = parseMessage(msgText)
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(msgText, preview = lp)
} else {
MsgContent.MCText(msgText)
}
}
else -> MsgContent.MCText(msgText)
}
}
fun updateMsgContent(msgContent: MsgContent): MsgContent {
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
}
}
suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent),
live = live
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
return updatedItem?.chatItem
}
return null
}
val liveMessage = cs.liveMessage
if (!live) {
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
sending()
}
if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem
sent = updateMessage(ei, cInfo, live)
} else if (liveMessage != null && liveMessage.sent) {
sent = updateMessage(liveMessage.chatItem, cInfo, live)
} else {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.ImagePreview -> {
chosenContent.value.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
files.add(file)
msgs.add(MsgContent.MCImage(if (chosenContent.value.lastIndex == index) msgText else "", preview.images[index]))
}
}
}
is ComposePreview.VoicePreview -> {
val chosenAudioVal = chosenAudio.value
if (chosenAudioVal != null) {
val file = chosenAudioVal.first.toFile().name
files.add((file))
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath)
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", chosenAudioVal.second / 1000))
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
val file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
}
}
}
}
val quotedItemId: Long? = when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id
else -> null
}
sent = null
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
)
}
if (sent == null && chosenContent.value.isNotEmpty()) {
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
}
}
clearState(live)
return sent
}
fun sendMessage() {
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
withApi {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
mc = updateMsgContent(oldMsgContent)
)
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
clearState()
}
}
}
else -> {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.ImagePreview -> {
chosenContent.value.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(context, it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
}
if (file != null) {
files.add(file)
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
}
}
}
is ComposePreview.VoicePreview -> {
val chosenAudioVal = chosenAudio.value
if (chosenAudioVal != null) {
val file = chosenAudioVal.first.toFile().name
files.add((file))
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
val file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
}
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
}
if (msgs.isNotEmpty()) {
withApi {
msgs.forEachIndexed { index, content ->
if (index > 0) delay(100)
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
file = files.getOrNull(index),
quotedItemId = if (index == 0) quotedItemId else null,
mc = content
)
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
}
clearState()
}
} else {
clearState()
}
}
withBGApi {
sendMessageAsync(null, false)
}
}
@@ -457,27 +523,11 @@ fun ComposeView(
fun allowVoiceToContact() {
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
val prefs = contact.mergedPreferences.toPreferences().copy(voice = ChatPreference(allow = FeatureAllowed.YES))
withApi {
val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs)
if (toContact != null) {
chatModel.updateContact(toContact)
}
chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
}
}
fun showDisabledVoiceAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.voice_messages_prohibited),
text = generalGetString(
if (chat.chatInfo is ChatInfo.Direct)
R.string.ask_your_contact_to_enable_voice
else
R.string.only_group_owners_can_enable_voice
)
)
}
fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
@@ -493,7 +543,14 @@ fun ComposeView(
}
fun cancelVoice() {
val filePath = recState.value.filePathNullable
recState.value = RecordingState.NotStarted
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
withBGApi {
RecorderNative.stopRecording?.invoke()
AudioPlayer.stop(filePath)
filePath?.let { File(it).delete() }
}
chosenAudio.value = null
}
@@ -502,6 +559,55 @@ fun ComposeView(
chosenFile.value = null
}
fun truncateToWords(s: String): String {
var acc = ""
val word = StringBuilder()
for (c in s) {
if (c.isLetter() || c.isDigit()) {
word.append(c)
} else {
acc = acc + word.toString() + c
word.clear()
}
}
return acc
}
suspend fun sendLiveMessage() {
val cs = composeState.value
val typedMsg = cs.message
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
val ci = sendMessageAsync(typedMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
}
} else if (cs.liveMessage == null) {
val cItem = chatModel.addLiveDummy(chat.chatInfo)
composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false))
}
}
fun liveMessageToSend(lm: LiveMessage, t: String): String? {
val s = if (t != lm.typedMsg) truncateToWords(t) else t
return if (s != lm.sentMsg) s else null
}
suspend fun updateLiveMessage() {
val typedMsg = composeState.value.message
val liveMessage = composeState.value.liveMessage
if (liveMessage != null) {
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
if (sentMsg != null) {
val ci = sendMessageAsync(sentMsg, live = true)
if (ci != null) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
}
} else if (liveMessage.typedMsg != typedMsg) {
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
}
}
}
@Composable
fun previewView() {
when (val preview = composeState.value.preview) {
@@ -564,47 +670,77 @@ fun ComposeView(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
IconButton(showChooseAttachment, enabled = attachEnabled) {
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
Icon(
Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach),
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
)
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.voiceMessageAllowed }
val needToAllowVoiceToContact = remember(chat.chatInfo) {
when (chat.chatInfo) {
is ChatInfo.Direct -> with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
else -> false
}
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when(it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
is RecordingState.NotStarted -> {}
}
}
}
val activity = LocalContext.current as Activity
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation) {
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage()
resetLinkPreview()
}
chatModel.removeLiveDummy()
}
}
}
SendMsgView(
composeState,
showVoiceRecordIcon = true,
allowedVoiceByPrefs = allowedVoiceByPrefs,
needToAllowVoiceToContact = needToAllowVoiceToContact,
recState,
chat.chatInfo is ChatInfo.Direct,
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact,
sendMessage = {
sendMessage()
resetLinkPreview()
},
::onMessageChange,
::onAudioAdded,
::allowVoiceToContact,
::showDisabledVoiceAlert,
textStyle
sendLiveMessage = ::sendLiveMessage,
updateLiveMessage = ::updateLiveMessage,
cancelLiveMessage = {
composeState.value = composeState.value.copy(liveMessage = null)
chatModel.removeLiveDummy()
},
onMessageChange = ::onMessageChange,
textStyle = textStyle
)
}
}
@@ -46,7 +46,7 @@ fun ComposeVoiceView(
val endTime = when {
finishedRecording -> duration.value
audioPlaying.value -> recordedDurationMs
else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt()
else -> MAX_VOICE_MILLIS_FOR_SENDING
}
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
@@ -20,6 +20,7 @@ import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggle
@Composable
fun ContactPreferencesView(
@@ -85,6 +86,14 @@ private fun ContactPreferencesLayout(
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.contact_preferences))
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400))
}
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
}
SectionSpacer()
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
applyPrefs(featuresAllowed.copy(fullDelete = it))
@@ -112,7 +121,8 @@ private fun FeatureSection(
onSelected: (ContactFeatureAllowed) -> Unit
) {
val enabled = FeatureEnabled.enabled(
user = ChatPreference(allow = allowFeature.value.allowed),
feature.asymmetric,
user = SimpleChatPreference(allow = allowFeature.value.allowed),
contact = pref.contactPreference
)
@@ -140,6 +150,50 @@ private fun FeatureSection(
SectionTextFooter(feature.enabledDescription(enabled))
}
@Composable
private fun TimedMessagesFeatureSection(
featuresAllowed: ContactFeaturesAllowed,
pref: ContactUserPreferenceTimed,
allowFeature: State<Boolean>,
onTTLUpdated: (Int?) -> Unit,
onSelected: (Boolean, Int?) -> Unit
) {
val enabled = FeatureEnabled.enabled(
ChatFeature.TimedMessages.asymmetric,
user = TimedMessagesPreference(allow = if (allowFeature.value) FeatureAllowed.YES else FeatureAllowed.NO),
contact = pref.contactPreference
)
SectionView(
ChatFeature.TimedMessages.text.uppercase(),
icon = ChatFeature.TimedMessages.iconFilled,
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
SectionItemView {
PreferenceToggle(
generalGetString(R.string.chat_preferences_you_allow),
checked = allowFeature.value,
) { allow ->
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
}
}
SectionDivider()
InfoRow(
generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
SectionDivider()
if (featuresAllowed.timedMessagesAllowed) {
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
TimedMessagesTTLPicker(ttl, onTTLUpdated)
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
}
}
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
@@ -153,6 +207,20 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
}
}
@Composable
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
val ttlValues = TimedMessagesPreference.ttlValues
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
SectionItemView {
ExposedDropDownSettingRow(
generalGetString(R.string.delete_after),
values.map { it to TimedMessagesPreference.ttlText(it) },
selection,
onSelected = onSelected
)
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(R.string.save_preferences_question),
@@ -0,0 +1,53 @@
package chat.simplex.app.views.chat
import android.Manifest
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCodeScanner
import com.google.accompanist.permissions.rememberPermissionState
@Composable
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ScanCodeLayout(verifyCode, close)
}
@Composable
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.scan_code), false)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = DEFAULT_PADDING)
) {
QRCodeScanner { text ->
verifyCode(text) {
if (it) {
close()
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.incorrect_code)
)
}
}
}
}
Text(stringResource(R.string.scan_code_from_contacts_app))
}
}
@@ -6,28 +6,36 @@ import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Build
import android.text.InputType
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.*
import android.widget.EditText
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat
@@ -35,184 +43,120 @@ import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatItem
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
import java.io.*
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
showVoiceRecordIcon: Boolean,
allowedVoiceByPrefs: Boolean,
recState: MutableState<RecordingState>,
isDirectChat: Boolean,
liveMessageAlertShown: SharedPreference<Boolean>,
needToAllowVoiceToContact: Boolean,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
allowedVoiceByPrefs: Boolean,
allowVoiceToContact: () -> Unit,
showDisabledVoiceAlert: () -> Unit,
sendMessage: () -> Unit,
sendLiveMessage: (suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
Column(Modifier.padding(vertical = 8.dp)) {
Box {
val cs = composeState.value
val attachEnabled = !composeState.value.editing
val filePath = rememberSaveable { mutableStateOf(null as String?) }
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && showVoiceRecordIcon && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
Box(if (recordingTimeRange.first == 0L)
Modifier
else
Modifier.clickable(false, onClick = {})
) {
NativeKeyboard(composeState, textStyle, onMessageChange)
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
NativeKeyboard(composeState, textStyle, onMessageChange)
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview) {
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
}
Box(Modifier.align(Alignment.BottomEnd)) {
val sendButtonSize = remember { Animatable(36f) }
val sendButtonAlpha = remember { Animatable(1f) }
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Making LiveMessage alive when screen orientation was changed
if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
}
}
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
} else if (!showVoiceButton) {
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
)
}
} else {
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.RECORD_AUDIO,
)
)
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (isActive) {
now = System.currentTimeMillis()
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
delay(100)
}
}
val stopRecordingAndAddAudio: () -> Unit = {
rec.stop()
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
}
val startStopRecording: () -> Unit = {
when {
showProgress -> ProgressIndicator()
showVoiceButton -> {
Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) }
when {
needToAllowVoiceToContact -> {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.allow_voice_messages_question),
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
confirmText = generalGetString(R.string.allow_verb),
dismissText = generalGetString(R.string.cancel_verb),
onConfirm = allowVoiceToContact,
)
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
DisallowedVoiceButton {
if (needToAllowVoiceToContact) {
showNeedToAllowVoiceAlert(allowVoiceToContact)
} else {
showDisabledVoiceAlert(isDirectChat)
}
}
}
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
recordingTimeRange = System.currentTimeMillis()..0L
filePath.value = rec.start(stopRecordingAndAddAudio)
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
!permissionsState.allPermissionsGranted ->
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
else ->
RecordVoiceView(recState, stopRecOnNextClick)
}
if (sendLiveMessage != null
&& updateLiveMessage != null
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
&& cs.contextItem is ComposeContextItem.NoContextItem) {
Spacer(Modifier.width(10.dp))
StartLiveMessageButton {
if (composeState.value.preview is ComposePreview.NoPreview) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
}
}
}
}
var stopRecOnNextClick by remember { mutableStateOf(false) }
val context = LocalContext.current
DisposableEffect(stopRecOnNextClick) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
if (stopRecOnNextClick) {
// Lock orientation to current orientation because screen rotation will break the recording
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
val cleanUp = { remove: Boolean ->
rec.stop()
AudioPlayer.stop(filePath.value)
if (remove) filePath.value?.let { File(it).delete() }
filePath.value = null
stopRecOnNextClick = false
recordingTimeRange = 0L..0L
}
LaunchedEffect(cs.preview) {
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
// Pressed on X icon in preview
cleanUp(true)
}
}
val interactionSource = interactionSourceWithTapDetection(
// It's just a key for triggering dropping a state in the compose function. Without it
// nothing will react on changed params like needToAllowVoiceToContact or allowedVoiceByPrefs
needToAllowVoiceToContact.toString() + allowedVoiceByPrefs.toString(),
onPress = {
if (filePath.value == null) startStopRecording()
},
onClick = {
// Voice not allowed or not granted voice record permission for the app
if (!allowedVoiceByPrefs || !permissionsState.allPermissionsGranted) return@interactionSourceWithTapDetection
if (!recordingInProgress.value && filePath.value != null) {
sendMessage()
cleanUp(false)
} else if (stopRecOnNextClick) {
stopRecordingAndAddAudio()
stopRecOnNextClick = false
} else {
// tapped and didn't hold a finger
stopRecOnNextClick = true
}
},
onCancel = startStopRecording,
onRelease = startStopRecording
)
val sendButtonModifier = if (recordingTimeRange.last != 0L)
Modifier.clip(CircleShape).background(color)
else
Modifier
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
Icon(
when {
recordingTimeRange.last != 0L -> Icons.Outlined.ArrowUpward
stopRecOnNextClick -> Icons.Filled.Stop
allowedVoiceByPrefs -> Icons.Filled.KeyboardVoice
else -> Icons.Outlined.KeyboardVoice
},
stringResource(R.string.icon_descr_record_voice_message),
tint = when {
recordingTimeRange.last != 0L -> Color.White
stopRecOnNextClick -> MaterialTheme.colors.primary
allowedVoiceByPrefs -> MaterialTheme.colors.primary
else -> HighOrLowlight
},
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.then(sendButtonModifier)
)
}
DisposableEffect(Unit) {
onDispose {
rec.stop()
}
cs.liveMessage?.sent == false && cs.message.isEmpty() -> {
CancelLiveMessageButton {
cancelLiveMessage?.invoke()
}
}
else -> {
val cs = composeState.value
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val disabled = !cs.sendEnabled() ||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
cs.endLiveDisabled
if (cs.liveMessage == null &&
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
cs.contextItem is ComposeContextItem.NoContextItem &&
sendLiveMessage != null && updateLiveMessage != null
) {
var showDropdown by rememberSaveable { mutableStateOf(false) }
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true }
DropdownMenu(
expanded = showDropdown,
onDismissRequest = { showDropdown = false },
Modifier.width(220.dp),
) {
ItemAction(
generalGetString(R.string.send_live_message),
Icons.Filled.Bolt,
onClick = {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
showDropdown = false
}
)
}
} else {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
}
}
}
@@ -234,7 +178,6 @@ private fun NativeKeyboard(
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
if (cs.contextItem is ComposeContextItem.QuotedItem) {
@@ -255,6 +198,7 @@ private fun NativeKeyboard(
) {
super.setOnReceiveContentListener(mimeTypes, listener)
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
@@ -312,6 +256,286 @@ private fun NativeKeyboard(
}
}
@Composable
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
DisposableEffect(Unit) { onDispose { rec.stop() } }
val stopRecordingAndAddAudio: () -> Unit = {
recState.value.filePathNullable?.let {
recState.value = RecordingState.Finished(it, rec.stop())
}
}
if (stopRecOnNextClick.value) {
LaunchedEffect(recState.value) {
if (recState.value is RecordingState.NotStarted) {
stopRecOnNextClick.value = false
}
}
// Lock orientation to current orientation because screen rotation will break the recording
LockToCurrentOrientationUntilDispose()
StopRecordButton(stopRecordingAndAddAudio)
} else {
val startRecording: () -> Unit = {
recState.value = RecordingState.Started(
filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
if (state is RecordingState.Started && progress != null) {
recState.value = if (!finished)
RecordingState.Started(state.filePath, progress)
else
RecordingState.Finished(state.filePath, progress)
}
},
)
}
val interactionSource = interactionSourceWithTapDetection(
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
onClick = {
if (stopRecOnNextClick.value) {
stopRecordingAndAddAudio()
} else {
// tapped and didn't hold a finger
stopRecOnNextClick.value = true
}
},
onCancel = stopRecordingAndAddAudio,
onRelease = stopRecordingAndAddAudio
)
RecordVoiceButton(interactionSource)
}
}
@Composable
private fun DisallowedVoiceButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Outlined.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(34.dp)
.padding(4.dp)
)
}
}
@Composable
private fun LockToCurrentOrientationUntilDispose() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as Activity
val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager
val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation
activity.requestedOrientation = when (rotation) {
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
}
@Composable
private fun StopRecordButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.Stop,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
Icon(
Icons.Filled.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(34.dp)
.padding(4.dp)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
}
@Composable
private fun CancelLiveMessageButton(
onClick: () -> Unit
) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.Close,
stringResource(R.string.icon_descr_cancel_live_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun SendMsgButton(
icon: ImageVector,
sizeDp: Animatable<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
enabled: Boolean,
sendMessage: () -> Unit,
onLongClick: (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.requiredSize(36.dp)
.combinedClickable(
onClick = sendMessage,
onLongClick = onLongClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(sizeDp.value.dp)
.padding(4.dp)
.alpha(alpha.value)
.clip(CircleShape)
.background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight)
.padding(3.dp)
)
}
}
@Composable
private fun StartLiveMessageButton(onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.requiredSize(36.dp)
.clickable(
onClick = onClick,
enabled = true,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.Bolt,
stringResource(R.string.icon_descr_send_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
private fun startLiveMessage(
scope: CoroutineScope,
send: suspend () -> Unit,
update: suspend () -> Unit,
sendButtonSize: Animatable<Float, AnimationVector1D>,
sendButtonAlpha: Animatable<Float, AnimationVector1D>,
composeState: MutableState<ComposeState>,
liveMessageAlertShown: SharedPreference<Boolean>
) {
fun run() {
scope.launch {
while (composeState.value.liveMessage != null) {
sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50))
}
sendButtonSize.snapTo(36f)
}
scope.launch {
while (composeState.value.liveMessage != null) {
sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50))
}
sendButtonAlpha.snapTo(1f)
}
scope.launch {
delay(3000)
while (composeState.value.liveMessage != null) {
update()
delay(3000)
}
}
}
fun start() = withBGApi {
if (composeState.value.liveMessage == null) {
send()
}
run()
}
if (liveMessageAlertShown.state.value) {
start()
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.live_message),
text = generalGetString(R.string.send_live_message_desc),
confirmText = generalGetString(R.string.send_verb),
onConfirm = {
liveMessageAlertShown.set(true)
start()
})
}
}
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.allow_voice_messages_question),
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
confirmText = generalGetString(R.string.allow_verb),
dismissText = generalGetString(R.string.cancel_verb),
onConfirm = onConfirm,
)
}
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.voice_messages_prohibited),
text = generalGetString(
if (isDirectChat)
R.string.ask_your_contact_to_enable_voice
else
R.string.only_group_owners_can_enable_voice
)
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -326,13 +550,14 @@ fun PreviewSendMsgView() {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -353,13 +578,14 @@ fun PreviewSendMsgViewEditing() {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -380,13 +606,14 @@ fun PreviewSendMsgViewInProgress() {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = true,
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -0,0 +1,137 @@
package chat.simplex.app.views.chat
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun VerifyCodeView(
displayName: String,
connectionCode: String?,
connectionVerified: Boolean,
verify: suspend (String?) -> Pair<Boolean, String>?,
close: () -> Unit,
) {
if (connectionCode != null) {
VerifyCodeLayout(
displayName,
connectionCode,
connectionVerified,
verifyCode = { newCode, cb ->
withBGApi {
val res = verify(newCode)
if (res != null) {
val (verified) = res
cb(verified)
if (verified) close()
}
}
}
)
}
}
@Composable
private fun VerifyCodeLayout(
displayName: String,
connectionCode: String,
connectionVerified: Boolean,
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.security_code), false)
val splitCode = splitToParts(connectionCode, 24)
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
if (connectionVerified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight)
Text(String.format(stringResource(R.string.is_verified), displayName))
} else {
Text(String.format(stringResource(R.string.is_not_verified), displayName))
}
}
SectionView {
QRCode(connectionCode, Modifier.aspectRatio(1f))
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(2f))
SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) {
Text(
splitCode,
fontFamily = FontFamily.Monospace,
fontSize = 18.sp,
maxLines = 20
)
}
val context = LocalContext.current
Box(Modifier.weight(1f)) {
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary)
}
}
Spacer(Modifier.weight(1f))
}
Text(
generalGetString(R.string.to_verify_compare),
Modifier.padding(bottom = DEFAULT_PADDING)
)
Row(
Modifier.padding(bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (connectionVerified) {
SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) {
verifyCode(null) {}
}
} else {
SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) {
ModalManager.shared.showModal {
ScanCodeView(verifyCode) { }
}
}
SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) {
verifyCode(connectionCode) { verified ->
if (!verified) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.incorrect_code)
)
}
}
}
}
}
}
}
private fun splitToParts(s: String, length: Int): String {
if (length >= s.length) return s
return (0..(s.length - 1) / length)
.map { s.drop(it * length).take(length) }
.joinToString(separator = "\n")
}
@@ -6,6 +6,7 @@ import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -23,6 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
@@ -32,7 +34,7 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
@Composable
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdated: (String?) -> Unit, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
@@ -45,6 +47,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedBy { it.displayName.lowercase() },
developerTools,
groupLink,
addMembers = {
withApi {
setGroupMembers(groupInfo, chatModel)
@@ -56,8 +59,23 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
showMemberInfo = { member ->
withApi {
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
val (_, code) = if (member.memberActive) {
try {
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
member to null
}
} else {
member to null
}
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
close()
}
}
}
}
},
@@ -78,8 +96,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
manageGroupLink = {
withApi {
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
}
}
)
@@ -128,6 +145,7 @@ fun GroupChatInfoLayout(
groupInfo: GroupInfo,
members: List<GroupMember>,
developerTools: Boolean,
groupLink: String?,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
@@ -163,7 +181,13 @@ fun GroupChatInfoLayout(
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView(manageGroupLink) { GroupLinkButton() }
SectionItemView(manageGroupLink) {
if (groupLink == null) {
CreateGroupLinkButton()
} else {
GroupLinkButton()
}
}
SectionDivider()
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
SectionItemView(onAddMembersClick) {
@@ -206,7 +230,7 @@ fun GroupChatInfoLayout(
}
@Composable
fun GroupChatInfoHeader(cInfo: ChatInfo) {
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
Column(
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
@@ -239,7 +263,7 @@ private fun GroupPreferencesButton(onClick: () -> Unit) {
}
@Composable
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -255,7 +279,7 @@ fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
}
@Composable
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
@@ -269,7 +293,7 @@ fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Uni
}
@Composable
fun MemberRow(member: GroupMember, user: Boolean = false) {
private fun MemberRow(member: GroupMember, user: Boolean = false) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -281,10 +305,15 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
) {
ProfileImage(size = 46.dp, member.image)
Column {
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
val s = member.memberStatus.shortText
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
Text(
@@ -304,7 +333,12 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
}
@Composable
fun GroupLinkButton() {
private fun MemberVerifiedShield() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight)
}
@Composable
private fun GroupLinkButton() {
Row(
Modifier
.fillMaxSize(),
@@ -320,6 +354,23 @@ fun GroupLinkButton() {
}
}
@Composable
private fun CreateGroupLinkButton() {
Row(
Modifier
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.AddLink,
stringResource(R.string.create_group_link),
tint = HighOrLowlight
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.create_group_link))
}
}
@Composable
fun EditGroupProfileButton() {
Row(
@@ -338,7 +389,7 @@ fun EditGroupProfileButton() {
}
@Composable
fun LeaveGroupButton() {
private fun LeaveGroupButton() {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -354,7 +405,7 @@ fun LeaveGroupButton() {
}
@Composable
fun DeleteGroupButton() {
private fun DeleteGroupButton() {
Row(
Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
@@ -382,6 +433,7 @@ fun PreviewGroupChatInfoLayout() {
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
)
}
@@ -1,10 +1,12 @@
package chat.simplex.app.views.chat.group
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -15,22 +17,32 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.GroupInfo
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?) {
var groupLink by remember { mutableStateOf(connReqContact) }
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
var creatingLink by rememberSaveable { mutableStateOf(false) }
val cxt = LocalContext.current
fun createLink() {
creatingLink = true
withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
onGroupLinkUpdated(groupLink)
creatingLink = false
}
}
LaunchedEffect(Unit) {
if (groupLink == null && !creatingLink) {
createLink()
}
}
GroupLinkLayout(
groupLink = groupLink,
createLink = {
withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
}
},
creatingLink,
createLink = ::createLink,
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
deleteLink = {
AlertManager.shared.showAlertMsg(
@@ -42,17 +54,22 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
onGroupLinkUpdated(null)
}
}
}
)
}
)
if (creatingLink) {
ProgressIndicator()
}
}
@Composable
fun GroupLinkLayout(
groupLink: String?,
creatingLink: Boolean,
createLink: () -> Unit,
share: () -> Unit,
deleteLink: () -> Unit
@@ -74,7 +91,7 @@ fun GroupLinkLayout(
verticalArrangement = Arrangement.SpaceEvenly
) {
if (groupLink == null) {
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
} else {
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
@@ -99,3 +116,18 @@ fun GroupLinkLayout(
}
}
@Composable
fun ProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
@@ -5,6 +5,7 @@ import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -12,6 +13,7 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,19 +23,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.SimplexServers
import chat.simplex.app.views.chat.SwitchAddressButton
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import kotlinx.datetime.Clock
@Composable
fun GroupMemberInfoView(
groupInfo: GroupInfo,
member: GroupMember,
connStats: ConnectionStats?,
connectionCode: String?,
chatModel: ChatModel,
close: () -> Unit,
closeAll: () -> Unit, // Close all open windows up to ChatView
@@ -49,6 +52,7 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
connectionCode,
getContactChat = { chatModel.getContactChat(it) },
knownDirectChat = {
withApi {
@@ -91,6 +95,32 @@ fun GroupMemberInfoView(
},
switchMemberAddress = {
switchMemberAddress(chatModel, groupInfo, member)
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.upsertGroupMember(
groupInfo,
mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
r
}
},
close,
)
}
}
}
)
}
@@ -120,12 +150,14 @@ fun GroupMemberInfoLayout(
connStats: ConnectionStats?,
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
connectionCode: String?,
getContactChat: (Long) -> Chat?,
knownDirectChat: (Chat) -> Unit,
newDirectChat: (Long) -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
switchMemberAddress: () -> Unit,
verifyClicked: () -> Unit,
) {
Column(
Modifier
@@ -141,17 +173,25 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
val contactId = member.memberContactId
if (contactId != null) {
val chat = getContactChat(contactId)
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directContact) {
if (member.memberActive) {
val contactId = member.memberContactId
if (contactId != null) {
SectionView {
OpenChatButton(onClick = { knownDirectChat(chat) })
}
SectionSpacer()
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
SectionView {
OpenChatButton(onClick = { newDirectChat(contactId) })
val chat = getContactChat(contactId)
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
OpenChatButton(onClick = { knownDirectChat(chat) })
if (connectionCode != null) {
SectionDivider()
}
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
OpenChatButton(onClick = { newDirectChat(contactId) })
if (connectionCode != null) {
SectionDivider()
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
}
SectionSpacer()
}
@@ -178,10 +218,10 @@ fun GroupMemberInfoLayout(
}
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
if (connStats != null) {
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
if (connStats != null) {
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
@@ -196,8 +236,8 @@ fun GroupMemberInfoLayout(
}
}
}
SectionSpacer()
}
SectionSpacer()
if (member.canBeRemoved(groupInfo)) {
SectionView {
@@ -224,12 +264,17 @@ fun GroupMemberInfoHeader(member: GroupMember) {
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.verified) {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
}
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (member.fullName != "" && member.fullName != member.displayName) {
Text(
member.fullName, style = MaterialTheme.typography.h2,
@@ -319,12 +364,14 @@ fun PreviewGroupMemberInfoLayout() {
connStats = null,
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
getContactChat = { Chat.sampleData },
knownDirectChat = {},
newDirectChat = {},
removeMember = {},
onRoleSelected = {},
switchMemberAddress = {},
verifyClicked = {},
)
}
}
@@ -18,7 +18,9 @@ import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.TimedMessagesTTLPicker
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
@Composable
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
@@ -74,18 +76,30 @@ private fun GroupPreferencesLayout(
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.group_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
val onTTLUpdated = { ttl: Int? ->
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
}
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
if (enable == GroupFeatureEnabled.ON) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
} else {
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
}
}
SectionSpacer()
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo) {
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
}
SectionSpacer()
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo) {
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo) {
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
}
if (groupInfo.canEdit) {
@@ -100,21 +114,34 @@ private fun GroupPreferencesLayout(
}
@Composable
private fun FeatureSection(feature: GroupFeature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) {
private fun FeatureSection(
feature: GroupFeature,
enableFeature: State<GroupFeatureEnabled>,
groupInfo: GroupInfo,
preferences: FullGroupPreferences,
onTTLUpdated: (Int?) -> Unit,
onSelected: (GroupFeatureEnabled) -> Unit
) {
SectionView {
val on = enableFeature.value == GroupFeatureEnabled.ON
val icon = if (on) feature.iconFilled else feature.icon
val iconTint = if (on) SimplexGreen else HighOrLowlight
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
if (groupInfo.canEdit) {
SectionItemView {
ExposedDropDownSettingRow(
PreferenceToggleWithIcon(
feature.text,
GroupFeatureEnabled.values().map { it to it.text },
enableFeature,
icon = icon,
iconTint = iconTint,
onSelected = onSelected
)
icon,
iconTint,
enableFeature.value == GroupFeatureEnabled.ON,
) { checked ->
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
}
}
if (timedOn) {
SectionDivider()
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
TimedMessagesTTLPicker(ttl, onTTLUpdated)
}
} else {
InfoRow(
@@ -123,6 +150,10 @@ private fun FeatureSection(feature: GroupFeature, enableFeature: State<GroupFeat
icon = icon,
iconTint = iconTint,
)
if (timedOn) {
SectionDivider()
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
}
}
}
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
@@ -136,7 +136,13 @@ fun GroupProfileLayout(
if (enabled) {
Text(
stringResource(R.string.save_group_profile),
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
modifier = Modifier.clickable {
saveProfile(groupProfile.copy(
displayName = displayName.value,
fullName = fullName.value,
image = profileImage.value
))
},
color = MaterialTheme.colors.primary
)
} else {
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
@@ -14,14 +15,15 @@ import chat.simplex.app.model.*
fun CIChatFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color
iconColor: Color,
icon: ImageVector? = null
) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(feature.iconFilled, feature.text, Modifier.size(15.dp), tint = iconColor)
Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(18.dp), tint = iconColor)
Text(
chatEventText(chatItem),
Modifier,
@@ -32,7 +32,7 @@ fun CIEventView(ci: ChatItem) {
if (memberDisplayName != null) {
chatEventTextView(
buildAnnotatedString {
withChatEventStyle(this, memberDisplayName)
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
}.plus(chatEventText(ci))
)
@@ -43,15 +43,11 @@ fun CIEventView(ci: ChatItem) {
}
}
private fun withChatEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
fun chatEventText(ci: ChatItem): AnnotatedString =
buildAnnotatedString {
withChatEventStyle(this, ci.content.text)
append(" ")
withChatEventStyle(this, ci.timestampText)
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
}
@Preview(showBackground = true)
@@ -0,0 +1,54 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun CIFeaturePreferenceView(
chatItem: ChatItem,
contact: Contact?,
feature: ChatFeature,
allowed: FeatureAllowed,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = HighOrLowlight)
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
val param = if (setParam) 86400 else null
val annotatedText = buildAnnotatedString {
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
withAnnotation(tag = "Accept", annotation = "Accept") {
withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") }
}
withStyle(chatEventStyle) { append(chatItem.timestampText) }
}
fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty()
ClickableText(
annotatedText,
onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } },
shouldConsumeEvent = ::accept
)
} else {
Text(chatItem.content.text + " " + chatItem.timestampText,
fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
}
}
}
@@ -0,0 +1,46 @@
package chat.simplex.app.views.chat.item
import SectionSpacer
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun CIInvalidJSONView(json: String) {
Row(Modifier
.clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Text(stringResource(R.string.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
}
}
@Composable
fun InvalidJSONView(json: String) {
Column {
Spacer(Modifier.height(DEFAULT_PADDING))
SectionView {
val context = LocalContext.current
SettingsActionItem(Icons.Outlined.Share, generalGetString(R.string.share_verb), click = {
shareText(context, json)
})
}
Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) {
Text(json)
}
}
}
@@ -3,62 +3,85 @@ package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.WarningYellow
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!chatItem.isDeletedContent) {
if (chatItem.meta.itemEdited) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.height(12.dp).padding(end = 1.dp),
contentDescription = stringResource(R.string.icon_descr_edited),
tint = metaColor,
)
}
CIStatusView(chatItem.meta.itemStatus, metaColor)
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = HighOrLowlight) {
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
if (chatItem.isDeletedContent) {
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
} else {
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
}
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
if (meta.itemEdited) {
StatusIconText(Icons.Outlined.Edit, color)
Spacer(Modifier.width(3.dp))
}
if (meta.disappearing) {
StatusIconText(Icons.Outlined.Timer, color)
val ttl = meta.itemTimed?.ttl
if (ttl != chatTTL) {
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
}
Spacer(Modifier.width(4.dp))
}
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
StatusIconText(icon, statusColor)
Spacer(Modifier.width(4.dp))
} else if (!meta.disappearing) {
StatusIconText(Icons.Filled.Circle, Color.Transparent)
Spacer(Modifier.width(4.dp))
}
Text(meta.timestampText, color = color, fontSize = 13.sp)
}
// the conditions in this function should match CIMetaText
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
val iconSpace = " "
var res = ""
if (meta.itemEdited) res += iconSpace
if (meta.itemTimed != null) {
res += iconSpace
val ttl = meta.itemTimed.ttl
if (ttl != chatTTL) {
res += TimedMessagesPreference.shortTtlText(ttl)
}
}
if (meta.statusIcon(HighOrLowlight) != null || !meta.disappearing) {
res += iconSpace
}
return res + meta.timestampText
}
@Composable
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
when (status) {
is CIStatus.SndSent -> {
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
}
is CIStatus.SndErrorAuth -> {
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
}
is CIStatus.SndError -> {
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow)
}
is CIStatus.RcvNew -> {
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
}
else -> {}
}
private fun StatusIconText(icon: ImageVector, color: Color) {
Icon(icon, null, Modifier.height(12.dp), tint = color)
}
@Preview
@@ -67,7 +90,8 @@ fun PreviewCIMetaView() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
),
null
)
}
@@ -78,7 +102,8 @@ fun PreviewCIMetaViewUnread() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.RcvNew()
)
),
null
)
}
@@ -89,7 +114,8 @@ fun PreviewCIMetaViewSendFailed() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.SndError("CMD SYNTAX")
)
),
null
)
}
@@ -99,7 +125,8 @@ fun PreviewCIMetaViewSendNoAuth() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
)
),
null
)
}
@@ -109,7 +136,8 @@ fun PreviewCIMetaViewSendSent() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
)
),
null
)
}
@@ -120,7 +148,8 @@ fun PreviewCIMetaViewEdited() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true
)
),
null
)
}
@@ -132,7 +161,8 @@ fun PreviewCIMetaViewEditedUnread() {
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.RcvNew()
)
),
null
)
}
@@ -144,7 +174,8 @@ fun PreviewCIMetaViewEditedSent() {
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true,
status=CIStatus.SndSent()
)
),
null
)
}
@@ -152,6 +183,7 @@ fun PreviewCIMetaViewEditedSent() {
@Composable
fun PreviewCIMetaViewDeletedContent() {
CIMetaView(
chatItem = ChatItem.getDeletedContentSampleData()
chatItem = ChatItem.getDeletedContentSampleData(),
null
)
}
@@ -34,6 +34,7 @@ fun CIVoiceView(
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
) {
Row(
@@ -63,7 +64,7 @@ fun CIVoiceView(
durationText(time / 1000)
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, play, pause, longClick)
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
val metaReserve = if (edited)
@@ -86,6 +87,7 @@ private fun VoiceLayout(
brokenAudio: Boolean,
sent: Boolean,
hasText: Boolean,
timedMessagesTTL: Int?,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
@@ -105,7 +107,7 @@ private fun VoiceLayout(
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@@ -115,7 +117,7 @@ private fun VoiceLayout(
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci)
CIMetaView(ci, timedMessagesTTL)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ComposeContextItem
import chat.simplex.app.views.chat.ComposeState
@@ -42,6 +43,7 @@ fun ChatItemView(
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
@@ -49,9 +51,10 @@ fun ChatItemView(
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val revealed = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.fullDeletionAllowed }
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
val onLinkLongClick = { _: String -> showMenu.value = true }
val live = composeState.value.liveMessage != null
Box(
modifier = Modifier
@@ -95,7 +98,7 @@ fun ChatItemView(
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
if (!cItem.meta.itemDeleted) {
if (!cItem.meta.itemDeleted && !live) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
@@ -131,7 +134,7 @@ fun ChatItemView(
})
}
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
@@ -147,7 +150,9 @@ fun ChatItemView(
}
)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
}
@@ -174,14 +179,14 @@ fun ChatItemView(
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted && !revealed.value) {
MarkedDeletedItemView(cItem, showMember = showMember)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted) {
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
EmojiItemView(cItem, cInfo.timedMessagesTTL)
MsgContentItemDropdownMenu()
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, longClick = { onLinkLongClick("") })
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
MsgContentItemDropdownMenu()
} else {
framedItemView()
@@ -194,7 +199,7 @@ fun ChatItemView(
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, showMember = showMember)
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
@@ -215,7 +220,7 @@ fun ChatItemView(
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
@@ -224,10 +229,16 @@ fun ChatItemView(
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
}
}
}
@@ -319,6 +330,7 @@ fun PreviewChatItemView() {
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
)
}
}
@@ -338,6 +350,7 @@ fun PreviewChatItemViewDeletedContent() {
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
)
}
}
@@ -18,7 +18,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
color = ReceivedColorLight,
@@ -35,7 +35,7 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@@ -49,7 +49,8 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
fun PreviewDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getDeletedContentSampleData()
ChatItem.getDeletedContentSampleData(),
null
)
}
}
@@ -15,13 +15,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
@Composable
fun EmojiItemView(chatItem: ChatItem) {
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
CIMetaView(chatItem)
CIMetaView(chatItem, timedMessagesTTL)
}
}
@@ -14,6 +14,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
@@ -47,6 +48,7 @@ fun FramedItemView(
scrollToItem: (Long) -> Unit = {},
) {
val sent = ci.chatDir.sent
val chatTTL = chatInfo.timedMessagesTTL
fun membership(): GroupMember? {
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
@@ -67,7 +69,7 @@ fun FramedItemView(
}
@Composable
fun ciDeletedView() {
fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) {
Row(
Modifier
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
@@ -78,15 +80,19 @@ fun FramedItemView(
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.marked_deleted_description),
Modifier.size(18.dp),
tint = if (isInDarkTheme()) FileDark else FileLight
)
if (icon != null) {
Icon(
icon,
caption,
Modifier.size(18.dp),
tint = if (isInDarkTheme()) FileDark else FileLight
)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) {
append(caption)
}
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
)
@@ -138,12 +144,12 @@ fun FramedItemView(
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (text != "") {
CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
}
}
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && ci.content.text.isEmpty() && ci.quotedItem == null
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier
.clip(RoundedCornerShape(18.dp))
@@ -158,9 +164,13 @@ fun FramedItemView(
Box(contentAlignment = Alignment.BottomEnd) {
Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted) { ciDeletedView() }
if (ci.meta.itemDeleted) {
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(R.string.live), false)
}
ci.quotedItem?.let { ciQuoteView(it) }
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
Column(
Modifier
@@ -176,36 +186,36 @@ fun FramedItemView(
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "") {
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
CIMarkdownText(ci, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, longClick = { onLinkLongClick("") })
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
if (mc.text != "") {
CIMarkdownText(ci, showMember, linkMode, uriHandler)
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
}
else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
}
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
CIMetaView(ci, chatTTL, metaColor)
}
}
}
@@ -214,15 +224,17 @@ fun FramedItemView(
@Composable
fun CIMarkdownText(
ci: ChatItem,
chatTTL: Int?,
showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode,
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
@@ -22,7 +22,7 @@ import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
Surface(
Modifier.clickable(onClick = {
AlertManager.shared.showAlertMsg(
@@ -45,7 +45,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@@ -59,7 +59,8 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
fun IntegrityErrorItemViewPreview() {
SimpleXTheme {
IntegrityErrorItemView(
ChatItem.getDeletedContentSampleData()
ChatItem.getDeletedContentSampleData(),
null
)
}
}
@@ -20,7 +20,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
@@ -37,7 +37,7 @@ fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
CIMetaView(ci, timedMessagesTTL)
}
}
}
@@ -51,7 +51,8 @@ fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
fun PreviewMarkedDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getSampleData(itemDeleted = true)
ChatItem.getSampleData(itemDeleted = true),
null
)
}
}
@@ -1,16 +1,20 @@
package chat.simplex.app.views.chat.item
import android.app.Activity
import android.content.ActivityNotFoundException
import android.util.Log
import androidx.annotation.IntRange
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
@@ -18,7 +22,9 @@ import androidx.compose.ui.unit.*
import androidx.core.text.BidiFormatter
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.detectGesture
import kotlinx.coroutines.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
@@ -40,13 +46,31 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
}
}
private val noTyping: AnnotatedString = AnnotatedString(" ")
private val typingIndicators: List<AnnotatedString> = listOf(
typing(FontWeight.Black) + typing() + typing(),
typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(),
typing() + typing(FontWeight.Bold) + typing(FontWeight.Black),
typing() + typing() + typing(FontWeight.Bold)
)
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
pushStyle(SpanStyle(color = HighOrLowlight, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
append(if (recent) typingIndicators[typingIdx] else noTyping)
}
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
AnnotatedString(".", SpanStyle(fontWeight = w))
@Composable
fun MarkdownText (
text: String,
formattedText: List<FormattedText>? = null,
sender: String? = null,
metaText: String? = null,
edited: Boolean = false,
meta: CIMeta? = null,
chatTTL: Int? = null,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
@@ -59,22 +83,60 @@ fun MarkdownText (
val textLayoutDirection = remember (text) {
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
}
val reserve = when {
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
edited -> " "
else -> " "
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
} else if (meta != null) {
reserveSpaceForMeta(meta, chatTTL)
} else {
" "
}
val scope = rememberCoroutineScope()
CompositionLocalProvider(
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
else
LocalLayoutDirection.current
) {
var timer: Job? by remember { mutableStateOf(null) }
var typingIdx by rememberSaveable { mutableStateOf(0) }
fun stopTyping() {
timer?.cancel()
timer = null
}
fun switchTyping() {
if (meta != null && meta.isLive && meta.recent) {
timer = timer ?: scope.launch {
while (isActive) {
typingIdx = (typingIdx + 1) % typingIndicators.size
delay(250)
}
}
} else {
stopTyping()
}
}
if (meta?.isLive == true) {
val activity = LocalContext.current as Activity
LaunchedEffect(meta.recent, meta.isLive) {
switchTyping()
}
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation) {
stopTyping()
}
}
}
}
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
append(text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
@@ -100,10 +162,13 @@ fun MarkdownText (
}
}
}
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.annotatedStringResource
import chat.simplex.app.views.usersettings.MarkdownHelpView
import chat.simplex.app.views.usersettings.simplexTeamUri
val bold = SpanStyle(fontWeight = FontWeight.Bold)
@@ -76,6 +77,15 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
}
Column(
Modifier.padding(vertical = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
MarkdownHelpView()
}
}
}
@@ -11,15 +11,20 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chat.group.deleteGroupDialog
import chat.simplex.app.views.chat.group.leaveGroupDialog
import chat.simplex.app.views.chat.item.InvalidJSONView
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ContactConnectionInfoView
@@ -75,6 +80,18 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
showMenu,
stopped
)
is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout(
chatLinkPreview = {
InvalidDataView()
},
click = {
ModalManager.shared.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
},
dropdownMenuItems = null,
showMenu,
stopped
)
}
}
@@ -320,6 +337,29 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
)
}
@Composable
private fun InvalidDataView() {
Row {
ProfileImage(72.dp, null, Icons.Filled.AccountCircle, HighOrLowlight)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
stringResource(R.string.invalid_data),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = Color.Red
)
val height = with(LocalDensity.current) { 46.sp.toDp() }
Spacer(Modifier.height(height))
}
}
}
fun markChatRead(c: Chat, chatModel: ChatModel) {
var chat = c
withApi {
@@ -22,12 +22,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.connectIfOpenedViaUri
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.onboarding.WhatsNewView
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
import chat.simplex.app.views.usersettings.SettingsView
import chat.simplex.app.views.usersettings.simplexTeamUri
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -41,9 +45,22 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
else newChatSheetState.value = NewChatSheetState.GONE
}
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
LaunchedEffect(chatModel.appOpenUrl.value) {
val url = chatModel.appOpenUrl.value
if (url != null) {
chatModel.appOpenUrl.value = null
connectIfOpenedViaUri(url, chatModel)
}
}
var searchInList by rememberSaveable { mutableStateOf("") }
val scaffoldState = rememberScaffoldState()
Scaffold(
@@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -63,11 +62,21 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
)
}
@Composable
fun VerifiedIcon() {
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
}
@Composable
fun chatPreviewTitle() {
when (cInfo) {
is ChatInfo.Direct ->
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
@@ -88,7 +97,6 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
linkMode = linkMode,
senderBold = true,
metaText = null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
@@ -29,7 +29,7 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
stopped
)
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection -> {}
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {}
}
}
@@ -30,7 +30,7 @@ fun CloseSheetBar(close: () -> Unit) {
@Composable
fun AppBarTitle(title: String, withPadding: Boolean = true) {
val padding = if (withPadding)
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
else
PaddingValues(bottom = DEFAULT_PADDING)
Text(
@@ -214,8 +214,8 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
}
@Composable
fun interactionSourceWithTapDetection(key: Any, onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember(key) { MutableInteractionSource() }
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(interactionSource) {
var firstTapTime = 0L
interactionSource.interactions.collect { interaction ->
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import android.util.Log
@@ -10,16 +11,15 @@ import androidx.compose.runtime.*
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.views.helpers.AudioPlayer.duration
import kotlinx.coroutines.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
interface Recorder {
val recordingInProgress: MutableState<Boolean>
fun start(onStop: () -> Unit): String
fun stop()
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
fun stop(): Int
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
@@ -27,8 +27,10 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
// Allows to stop the recorder from outside without having the recorder in a variable
var stopRecording: (() -> Unit)? = null
}
override val recordingInProgress = mutableStateOf(false)
private var recorder: MediaRecorder? = null
private var progressJob: Job? = null
private var filePath: String? = null
private var recStartedAt: Long? = null
private fun initRecorder() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(SimplexApp.context)
@@ -36,9 +38,8 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
MediaRecorder()
}
override fun start(onStop: () -> Unit): String {
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
AudioPlayer.stop()
recordingInProgress.value = true
val rec: MediaRecorder
recorder = initRecorder().also { rec = it }
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
@@ -47,28 +48,37 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(-1) // TODO set limit
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
rec.setMaxFileSize(recordedBytesLimit)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
rec.setOutputFile(filePath)
val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
filePath = path
rec.setOutputFile(path)
rec.prepare()
rec.start()
rec.setOnInfoListener { mr, what, extra ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
stop()
onStop()
recStartedAt = System.currentTimeMillis()
progressJob = CoroutineScope(Dispatchers.Default).launch {
while(isActive) {
onProgressUpdate(progress(), false)
delay(50)
}
}.apply {
invokeOnCompletion {
onProgressUpdate(realDuration(path), true)
}
}
stopRecording = { stop(); onStop() }
return filePath
rec.setOnInfoListener { _, what, _ ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED || what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) {
stop()
}
}
stopRecording = { stop() }
return path
}
override fun stop() {
if (!recordingInProgress.value) return
override fun stop(): Int {
val path = filePath ?: return 0
stopRecording = null
recordingInProgress.value = false
recorder?.metrics?.
runCatching {
recorder?.stop()
}
@@ -76,16 +86,25 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
recorder?.reset()
}
runCatching {
// release all resources
recorder?.release()
}
// Await coroutine finishes in order to send real duration to it's listener
runBlocking {
progressJob?.cancelAndJoin()
}
progressJob = null
filePath = null
recorder = null
return (realDuration(path) ?: 0).also { recStartedAt = null }
}
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
stop()
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
}
private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() }
/**
* Return real duration from [AudioPlayer] if it's possible (should always be possible).
* As a fallback, return internally counted duration
* */
private fun realDuration(path: String): Int? = duration(path) ?: progress()
}
object AudioPlayer {
@@ -251,8 +270,8 @@ object AudioPlayer {
audioPlaying.value = false
}
fun duration(filePath: String): Int {
var res = 0
fun duration(filePath: String): Int? {
var res: Int? = null
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.prepare()
@@ -28,6 +28,22 @@ fun SimpleButton(text: String, icon: ImageVector,
}
}
@Composable
fun SimpleButton(
text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
disabled: Boolean,
click: () -> Unit
) {
SimpleButtonFrame(click, disabled = disabled) {
Icon(
icon, text, tint = if (disabled) HighOrLowlight else color,
modifier = Modifier.padding(end = 8.dp)
)
Text(text, style = MaterialTheme.typography.caption, color = if (disabled) HighOrLowlight else color)
}
}
@Composable
fun SimpleButtonIconEnded(
text: String,
@@ -228,7 +228,7 @@ const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
const val MAX_FILE_SIZE: Long = 8000000
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
Step3_SetNotificationsMode,
OnboardingComplete
}
@@ -34,6 +35,9 @@ fun CreateProfile(chatModel: ChatModel) {
.padding(20.dp)
) {
CreateProfilePanel(chatModel)
LaunchedEffect(Unit) {
setLastVersionDefault(chatModel)
}
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {
@@ -0,0 +1,68 @@
package chat.simplex.app.views.onboarding
import androidx.annotation.StringRes
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.NotificationsMode
import chat.simplex.app.views.usersettings.changeNotificationsMode
@Composable
fun SetNotificationsMode(m: ChatModel) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
AppBarTitle(stringResource(R.string.onboarding_notifications_mode_title), false)
val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) }
Text(stringResource(R.string.onboarding_notifications_mode_subtitle))
Spacer(Modifier.padding(DEFAULT_PADDING_HALF))
NotificationButton(currentMode, NotificationsMode.OFF, R.string.onboarding_notifications_mode_off, R.string.onboarding_notifications_mode_off_desc)
NotificationButton(currentMode, NotificationsMode.PERIODIC, R.string.onboarding_notifications_mode_periodic, R.string.onboarding_notifications_mode_periodic_desc)
NotificationButton(currentMode, NotificationsMode.SERVICE, R.string.onboarding_notifications_mode_service, R.string.onboarding_notifications_mode_service_desc)
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage) {
changeNotificationsMode(currentMode.value, m)
}
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
}
@Composable
private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mode: NotificationsMode, @StringRes title: Int, @StringRes description: Int) {
TextButton(
onClick = { currentMode.value = mode },
border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight.copy(alpha = 0.5f)),
shape = RoundedCornerShape(15.dp),
) {
Column(Modifier.padding(bottom = 6.dp).padding(horizontal = 8.dp)) {
Text(
stringResource(title),
style = MaterialTheme.typography.h2,
fontWeight = FontWeight.Medium,
color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(annotatedStringResource(description), color = MaterialTheme.colors.onBackground, lineHeight = 24.sp)
}
}
Spacer(Modifier.height(DEFAULT_PADDING))
}
@@ -25,7 +25,6 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
@@ -105,14 +104,14 @@ private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: I
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
if (user == null) {
ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
OnboardingActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
} else {
ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
OnboardingActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
}
}
@Composable
private fun ActionButton(
fun OnboardingActionButton(
@StringRes labelId: Int,
onboarding: OnboardingStage?,
onboardingStage: MutableState<OnboardingStage?>,
@@ -0,0 +1,259 @@
package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
@Composable
fun featureDescription(icon: ImageVector, titleId: Int, descrId: Int, link: String?) {
@Composable
fun linkButton(link: String) {
val uriHandler = LocalUriHandler.current
Icon(
Icons.Outlined.OpenInNew, stringResource(titleId), tint = MaterialTheme.colors.primary,
modifier = Modifier
.clickable { uriHandler.openUri(link) }
)
}
Column(
horizontalAlignment = Alignment.Start
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(icon, stringResource(titleId), tint = HighOrLowlight)
Text(
generalGetString(titleId),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Medium
)
if (link != null) {
linkButton(link)
}
}
Text(generalGetString(descrId))
}
}
@Composable
fun pagination() {
Row(
Modifier
.padding(bottom = 16.dp)
) {
if (currentVersion.value > 0) {
val prev = currentVersion.value - 1
Surface(shape = RoundedCornerShape(20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clickable { currentVersion.value = prev }
.padding(8.dp)
) {
Icon(Icons.Outlined.ArrowBackIosNew, "previous", tint = MaterialTheme.colors.primary)
Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary)
}
}
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (currentVersion.value < versionDescriptions.lastIndex) {
val next = currentVersion.value + 1
Surface(shape = RoundedCornerShape(20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clickable { currentVersion.value = next }
.padding(8.dp)
) {
Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary)
Icon(Icons.Outlined.ArrowForwardIos, "next", tint = MaterialTheme.colors.primary)
}
}
}
}
}
val v = versionDescriptions[currentVersion.value]
ModalView(close = close) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
String.format(generalGetString(R.string.new_in_version), v.version),
Modifier
.fillMaxWidth()
.padding(DEFAULT_PADDING),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1,
fontWeight = FontWeight.Normal,
color = HighOrLowlight
)
v.features.forEach { feature ->
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link)
}
if (!viaSettings) {
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
Modifier.fillMaxWidth(), contentAlignment = Alignment.Center
) {
Text(
generalGetString(R.string.ok),
modifier = Modifier.clickable(onClick = close),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.primary
)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
pagination()
}
}
}
private data class FeatureDescription(
val icon: ImageVector,
val titleId: Int,
val descrId: Int,
val link: String? = null
)
private data class VersionDescription(
val version: String,
val features: List<FeatureDescription>
)
private val versionDescriptions: List<VersionDescription> = listOf(
VersionDescription(
version = "v4.2",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.VerifiedUser,
titleId = R.string.v4_2_security_assessment,
descrId = R.string.v4_2_security_assessment_desc,
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
),
FeatureDescription(
icon = Icons.Outlined.Group,
titleId = R.string.v4_2_group_links,
descrId = R.string.v4_2_group_links_desc
),
FeatureDescription(
icon = Icons.Outlined.Check,
titleId = R.string.v4_2_auto_accept_contact_requests,
descrId = R.string.v4_2_auto_accept_contact_requests_desc
),
)
),
VersionDescription(
version = "v4.3",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.Mic,
titleId = R.string.v4_3_voice_messages,
descrId = R.string.v4_3_voice_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.DeleteForever,
titleId = R.string.v4_3_irreversible_message_deletion,
descrId = R.string.v4_3_irreversible_message_deletion_desc
),
FeatureDescription(
icon = Icons.Outlined.WifiTethering,
titleId = R.string.v4_3_improved_server_configuration,
descrId = R.string.v4_3_improved_server_configuration_desc
),
FeatureDescription(
icon = Icons.Outlined.VisibilityOff,
titleId = R.string.v4_3_improved_privacy_and_security,
descrId = R.string.v4_3_improved_privacy_and_security_desc
),
)
),
VersionDescription(
version = "v4.4",
features = listOf(
FeatureDescription(
icon = Icons.Outlined.Timer,
titleId = R.string.v4_4_disappearing_messages,
descrId = R.string.v4_4_disappearing_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.Pending,
titleId = R.string.v4_4_live_messages,
descrId = R.string.v4_4_live_messages_desc
),
FeatureDescription(
icon = Icons.Outlined.VerifiedUser,
titleId = R.string.v4_4_verify_connection_security,
descrId = R.string.v4_4_verify_connection_security_desc
)
)
)
)
private val lastVersion = versionDescriptions.last().version
fun setLastVersionDefault(m: ChatModel) {
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
}
fun shouldShowWhatsNew(m: ChatModel): Boolean {
val v = m.controller.appPrefs.whatsNewVersion.get()
setLastVersionDefault(m)
return v != lastVersion
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewWhatsNewView() {
SimpleXTheme {
WhatsNewView(
viaSettings = true,
close = {}
)
}
}
@@ -33,6 +33,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) }
val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) }
val networkSMPPingInterval = remember { mutableStateOf(currentCfgVal.smpPingInterval) }
val networkSMPPingCount = remember { mutableStateOf(currentCfgVal.smpPingCount) }
val networkEnableKeepAlive = remember { mutableStateOf(currentCfgVal.enableKeepAlive) }
val networkTCPKeepIdle: MutableState<Int>
val networkTCPKeepIntvl: MutableState<Int>
@@ -48,10 +49,6 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
}
fun buildCfg(): NetCfg {
val socksProxy = currentCfg.value.socksProxy
val tcpConnectTimeout = networkTCPConnectTimeout.value
val tcpTimeout = networkTCPTimeout.value
val smpPingInterval = networkSMPPingInterval.value
val enableKeepAlive = networkEnableKeepAlive.value
val tcpKeepAlive = if (enableKeepAlive) {
val keepIdle = networkTCPKeepIdle.value
@@ -62,11 +59,12 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
null
}
return NetCfg(
socksProxy = socksProxy,
tcpConnectTimeout = tcpConnectTimeout,
tcpTimeout = tcpTimeout,
socksProxy = currentCfg.value.socksProxy,
tcpConnectTimeout = networkTCPConnectTimeout.value,
tcpTimeout = networkTCPTimeout.value,
tcpKeepAlive = tcpKeepAlive,
smpPingInterval = smpPingInterval
smpPingInterval = networkSMPPingInterval.value,
smpPingCount = networkSMPPingCount.value
)
}
@@ -74,6 +72,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
networkTCPConnectTimeout.value = cfg.tcpConnectTimeout
networkTCPTimeout.value = cfg.tcpTimeout
networkSMPPingInterval.value = cfg.smpPingInterval
networkSMPPingCount.value = cfg.smpPingCount
networkEnableKeepAlive.value = cfg.enableKeepAlive
if (cfg.tcpKeepAlive != null) {
networkTCPKeepIdle.value = cfg.tcpKeepAlive.keepIdle
@@ -113,6 +112,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
networkTCPConnectTimeout,
networkTCPTimeout,
networkSMPPingInterval,
networkSMPPingCount,
networkEnableKeepAlive,
networkTCPKeepIdle,
networkTCPKeepIntvl,
@@ -129,6 +129,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
networkTCPConnectTimeout: MutableState<Long>,
networkTCPTimeout: MutableState<Long>,
networkSMPPingInterval: MutableState<Long>,
networkSMPPingCount: MutableState<Int>,
networkEnableKeepAlive: MutableState<Boolean>,
networkTCPKeepIdle: MutableState<Int>,
networkTCPKeepIntvl: MutableState<Int>,
@@ -170,7 +171,14 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
SectionItemView {
TimeoutSettingRow(
stringResource(R.string.network_option_ping_interval), networkSMPPingInterval,
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000), secondsLabel
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel
)
}
SectionDivider()
SectionItemView {
IntSettingRow(
stringResource(R.string.network_option_ping_count), networkSMPPingCount,
listOf(1, 2, 3, 5, 8), ""
)
}
SectionDivider()
@@ -412,6 +420,7 @@ fun PreviewAdvancedNetworkSettingsLayout() {
networkTCPConnectTimeout = remember { mutableStateOf(10_000000) },
networkTCPTimeout = remember { mutableStateOf(10_000000) },
networkSMPPingInterval = remember { mutableStateOf(10_000000) },
networkSMPPingCount = remember { mutableStateOf(3) },
networkEnableKeepAlive = remember { mutableStateOf(true) },
networkTCPKeepIdle = remember { mutableStateOf(10) },
networkTCPKeepIntvl = remember { mutableStateOf(10) },
@@ -17,18 +17,13 @@ import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AppBarTitle
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkdownHelpView() {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.how_to_use_markdown), false)
Text(stringResource(R.string.you_can_use_markdown_to_format_messages__prompt))
Spacer(Modifier.height(DEFAULT_PADDING))
val bold = stringResource(R.string.bold)
@@ -123,10 +123,8 @@ fun NetworkAndServersView(
}
SectionDivider()
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
if (developerTools) {
SectionDivider()
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
SectionDivider()
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
Spacer(Modifier.height(8.dp))
SectionView(generalGetString(R.string.settings_section_title_calls)) {
@@ -46,25 +46,6 @@ enum class NotificationPreviewMode {
fun NotificationsSettingsView(
chatModel: ChatModel,
) {
val onNotificationsModeSelected = { mode: NotificationsMode ->
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.notificationsMode.value = mode
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.safeStopService(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
}
val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode ->
chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name)
chatModel.notificationPreviewMode.value = mode
@@ -76,7 +57,7 @@ fun NotificationsSettingsView(
showPage = { page ->
ModalManager.shared.showModalCloseable(true) {
when (page) {
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected)
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode) { changeNotificationsMode(it, chatModel) }
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)
}
}
@@ -159,7 +140,7 @@ fun NotificationPreviewView(
}
// mode, name, description
fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
private fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
val res = ArrayList<ValueTitleDesc<NotificationsMode>>()
res.add(
ValueTitleDesc(
@@ -211,3 +192,23 @@ fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
)
return res
}
fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) {
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
}
chatModel.notificationsMode.value = mode
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.safeStopService(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
}
@@ -69,14 +69,19 @@ private fun PreferencesLayout(
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.your_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.allow) }
TimedMessagesFeatureSection(timedMessages) {
applyPrefs(preferences.copy(timedMessages = TimedMessagesPreference(allow = if (it) FeatureAllowed.YES else FeatureAllowed.NO)))
}
SectionSpacer()
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
FeatureSection(ChatFeature.FullDelete, allowFullDeletion) {
applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it)))
applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
FeatureSection(ChatFeature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = ChatPreference(allow = it)))
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
}
SectionSpacer()
ResetSaveButtons(
@@ -103,6 +108,22 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllo
SectionTextFooter(feature.allowDescription(allowFeature.value))
}
@Composable
private fun TimedMessagesFeatureSection(allowFeature: State<FeatureAllowed>, onSelected: (Boolean) -> Unit) {
SectionView {
SectionItemView {
PreferenceToggleWithIcon(
ChatFeature.TimedMessages.text,
ChatFeature.TimedMessages.icon,
HighOrLowlight,
allowFeature.value == FeatureAllowed.ALWAYS || allowFeature.value == FeatureAllowed.YES,
onSelected
)
}
}
SectionTextFooter(ChatFeature.TimedMessages.allowDescription(allowFeature.value))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
@@ -5,6 +5,7 @@ import SectionItemView
import SectionSpacer
import SectionView
import android.content.res.Configuration
import android.icu.util.VersionInfo
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -34,6 +35,7 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.CreateLinkTab
import chat.simplex.app.views.newchat.CreateLinkView
import chat.simplex.app.views.onboarding.SimpleXInfo
import chat.simplex.app.views.onboarding.WhatsNewView
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
@@ -56,7 +58,14 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
showVersion = {
withApi {
val info = chatModel.controller.apiGetVersion()
if (info != null) {
ModalManager.shared.showModal { VersionInfoView(info) }
}
}
}
)
}
}
@@ -88,7 +97,7 @@ fun SettingsLayout(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
// showVideoChatPrototype: () -> Unit
showVersion: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
@@ -138,9 +147,9 @@ fun SettingsLayout(
SectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
SettingsActionItem(Icons.Outlined.Add, stringResource(R.string.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
SectionDivider()
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
SectionDivider()
@@ -169,7 +178,7 @@ fun SettingsLayout(
}
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
// SectionDivider()
AppVersionItem()
AppVersionItem(showVersion)
}
}
}
@@ -344,8 +353,8 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable private fun AppVersionItem() {
SectionItemView() {
@Composable private fun AppVersionItem(showVersion: () -> Unit) {
SectionItemView(showVersion) {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@@ -488,9 +497,9 @@ fun PreviewSettingsLayout() {
setPerformLA = {},
showModal = { {} },
showSettingsModal = { {} },
showCustomModal = { {}},
showCustomModal = { {} },
showTerminal = {},
// showVideoChatPrototype = {}
showVersion = {}
)
}
}
@@ -44,8 +44,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
close,
saveProfile = { displayName, fullName, image ->
withApi {
val p = Profile(displayName, fullName, image)
val newProfile = chatModel.controller.apiUpdateProfile(p)
val newProfile = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image))
if (newProfile != null) {
chatModel.currentUser.value?.profile?.profileId?.let {
chatModel.updateUserProfile(newProfile.toLocalProfile(it))
@@ -0,0 +1,29 @@
package chat.simplex.app.views.usersettings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import chat.simplex.app.BuildConfig
import chat.simplex.app.R
import chat.simplex.app.model.CoreVersionInfo
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.AppBarTitle
@Composable
fun VersionInfoView(info: CoreVersionInfo) {
Column(
Modifier.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start
) {
AppBarTitle(stringResource(R.string.app_version_title), false)
Text(String.format(stringResource(R.string.app_version_name), BuildConfig.VERSION_NAME))
Text(String.format(stringResource(R.string.app_version_code), BuildConfig.VERSION_CODE))
Text(String.format(stringResource(R.string.core_version), info.version))
Text(String.format(stringResource(R.string.core_build_timestamp), info.buildTimestamp))
Text(String.format(stringResource(R.string.core_simplexmq_version), info.simplexmqVersion, info.simplexmqCommit.substring(startIndex = 0, endIndex = 7)))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,925 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="thousand_abbreviation">k</string>
<string name="connect_via_contact_link">Se connecter via le lien du contact \?</string>
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="profile_will_be_sent_to_contact_sending_link">Votre profil va être envoyé au contact qui vous a envoyé ce lien.</string>
<string name="you_will_join_group">Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe.</string>
<string name="connect_via_link_verb">Se connecter</string>
<string name="connect_via_group_link">Se connecter via le lien du groupe \?</string>
<string name="connect_via_invitation_link">Se connecter via un lien d\'invitation \?</string>
<string name="server_error">erreur</string>
<string name="server_connecting">connexion</string>
<string name="server_connected">connecté</string>
<string name="display_name_connection_established">connexion établie</string>
<string name="display_name_invited_to_connect">invité à se connecter</string>
<string name="simplex_link_invitation">Invitation unique SimpleX</string>
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode">Liens SimpleX</string>
<string name="simplex_link_mode_description">Description</string>
<string name="error_deleting_contact">Erreur lors de la suppression du contact</string>
<string name="error_joining_group">Erreur lors de la liaison avec le groupe</string>
<string name="sender_cancelled_file_transfer">L\'expéditeur a annulé le transfert de fichiers.</string>
<string name="deleted_description">supprimé</string>
<string name="marked_deleted_description">supprimé</string>
<string name="unknown_message_format">format de message inconnu</string>
<string name="display_name_connecting">connexion…</string>
<string name="description_you_shared_one_time_link_incognito">vous avez partagé un lien unique en incognito</string>
<string name="description_via_group_link">via le lien de groupe</string>
<string name="description_via_contact_address_link">via le lien d\'adresse du contact</string>
<string name="description_via_contact_address_link_incognito">mode incognito via le lien d\'adresse du contact</string>
<string name="simplex_link_group">Lien de groupe SimpleX</string>
<string name="simplex_link_mode_browser">Via navigateur</string>
<string name="simplex_link_mode_browser_warning">Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge.</string>
<string name="network_error_desc">Vérifiez votre connexion réseau avec <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> et réessayez.</string>
<string name="error_receiving_file">Erreur lors de la réception du fichier</string>
<string name="sender_may_have_deleted_the_connection_request">L\'expéditeur a peut-être supprimé la demande de connexion.</string>
<string name="connected_to_server_to_receive_messages_from_contact">Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact.</string>
<string name="sending_files_not_yet_supported">l\'envoi de fichiers n\'est pas encore supporté</string>
<string name="sender_you_pronoun">vous</string>
<string name="description_via_group_link_incognito">mode incognito via le lien de groupe</string>
<string name="simplex_link_contact">Adresse de contact SimpleX</string>
<string name="trying_to_connect_to_server_to_receive_messages">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact.</string>
<string name="receiving_files_not_yet_supported">la réception de fichiers n\'est pas encore supportée</string>
<string name="connection_local_display_name">connexion <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="description_you_shared_one_time_link">vous avez partagé un lien unique</string>
<string name="description_via_one_time_link">via un lien unique</string>
<string name="description_via_one_time_link_incognito">mode incognito via un lien unique</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs SMP sont au bon format, séparées par des lignes et ne sont pas dupliquées.</string>
<string name="error_setting_network_config">Erreur lors de la mise à jour de la configuration réseau</string>
<string name="error_creating_address">Erreur lors de la création de l\'adresse</string>
<string name="contact_already_exists">Contact déjà existant</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre.</string>
<string name="connection_error">Erreur de connexion</string>
<string name="error_adding_members">Erreur lors de l\'ajout de membre·s</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="invalid_message_format">format de message invalide</string>
<string name="simplex_link_mode_full">Lien entier</string>
<string name="error_saving_smp_servers">Erreur lors de la sauvegarde des serveurs SMP</string>
<string name="cannot_receive_file">Impossible de recevoir le fichier</string>
<string name="invalid_connection_link">Lien de connection invalide</string>
<string name="connection_timeout">Délai de connexion</string>
<string name="error_sending_message">Erreur lors de l\'envoi du message</string>
<string name="you_are_already_connected_to_vName_via_this_link">Vous êtes déjà connecté à <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
<string name="connection_error_auth">Erreur de connexion (AUTH)</string>
<string name="connection_error_auth_desc">A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s\'agir d\'un bug - veuillez le signaler.
\nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d\'une connexion réseau stable.</string>
<string name="error_accepting_contact_request">Erreur de validation de la demande de contact</string>
<string name="error_deleting_group">Erreur lors de la suppression du groupe</string>
<string name="error_deleting_contact_request">Erreur lors de la suppression du contact</string>
<string name="error_deleting_pending_contact_connection">Erreur lors de la suppression de la connexion en attente</string>
<string name="error_changing_address">Erreur de changement d\'adresse</string>
<string name="error_smp_test_failed_at_step">Échec du test à l\'étape %s.</string>
<string name="error_smp_test_certificate">Il est possible que l\'empreinte du certificat dans l\'adresse du serveur soit incorrecte</string>
<string name="smp_server_test_connect">Se connecter</string>
<string name="smp_server_test_create_queue">Créer une file d\'attente</string>
<string name="smp_server_test_secure_queue">File d\'attente sécurisée</string>
<string name="smp_server_test_delete_queue">Supprimer la file d\'attente</string>
<string name="smp_server_test_disconnect">Se déconnecter</string>
<string name="icon_descr_instant_notifications">Notifications instantanées</string>
<string name="service_notifications">Notifications instantanées !</string>
<string name="service_notifications_disabled">Les notifications instantanées sont désactivées !</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Il peut être désactivé via les paramètres</b> - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.</string>
<string name="turning_off_service_and_periodic">L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres.</string>
<string name="periodic_notifications">Notifications périodiques</string>
<string name="periodic_notifications_disabled">Les notifications périodiques sont désactivées !</string>
<string name="enter_passphrase_notification_title">Une phrase secrète est nécessaire</string>
<string name="turn_off_battery_optimization">Pour l\'utiliser, veuillez <b>désactiver l\'optimisation de la batterie</b> pour <xliff:g id="appName">SimpleX</xliff:g> dans la prochaine fenêtre de dialogue. Sinon, les notifications seront désactivées.</string>
<string name="error_smp_test_server_auth">Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe</string>
<string name="periodic_notifications_desc">L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs.</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Pour protéger votre vie privée, au lieu des notifications push, l\'application possède un <b><xliff:g id="appName">SimpleX</xliff:g> service de fond</b> - il utilise quelques pour cent de la batterie par jour.</string>
<string name="hide_notification">Cacher</string>
<string name="settings_notification_preview_mode_title">Montrer l\'aperçu</string>
<string name="notification_preview_mode_contact">Nom du contact</string>
<string name="notification_preview_somebody">Contact masqué :</string>
<string name="notification_preview_new_message">nouveau message</string>
<string name="notification_new_contact_request">Nouvelle demande de contact</string>
<string name="notification_contact_connected">Connecté</string>
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
<string name="la_notice_turn_on">Activer</string>
<string name="auth_simplex_lock_turned_on">SimpleX Lock activé</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l\'application après 30 secondes en arrière-plan.</string>
<string name="auth_unlock">Déverrouiller</string>
<string name="auth_enable_simplex_lock">Activer SimpleX Lock</string>
<string name="auth_disable_simplex_lock">Désactiver SimpleX Lock</string>
<string name="auth_unavailable">Authentification indisponible</string>
<string name="auth_device_authentication_is_disabled_turning_off">L\'authentification de l\'appareil est désactivée. Désactivation de SimpleX Lock.</string>
<string name="auth_open_chat_console">Ouvrir la console du chat</string>
<string name="message_delivery_error_title">Erreur de distribution du message</string>
<string name="message_delivery_error_desc">Il est fort probable que ce contact ait supprimé la connexion avec vous.</string>
<string name="reply_verb">Répondre</string>
<string name="share_verb">Partager</string>
<string name="copy_verb">Copier</string>
<string name="delete_verb">Supprimer</string>
<string name="save_verb">Sauvegarder</string>
<string name="edit_verb">Modifier</string>
<string name="reveal_verb">Révéler</string>
<string name="hide_verb">Cacher</string>
<string name="allow_verb">Autoriser</string>
<string name="delete_message__question">Supprimer le message\?</string>
<string name="for_me_only">Supprimer pour moi</string>
<string name="your_chats">Vos chats</string>
<string name="notification_preview_mode_message">Texte du message</string>
<string name="notification_preview_mode_hidden">Caché</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Pour protéger vos informations, activez la fonction SimpleX Lock.
\nVous serez invité à confirmer l\'authentification avant que cette fonction ne soit activée.</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">L\'authentification de l\'appareil n\'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l\'authentification de l\'appareil.</string>
<string name="database_initialization_error_desc">La base de données ne fonctionne pas correctement. Appuyez ici pour en savoir plus.</string>
<string name="ntf_channel_calls">Appels SimpleX Chat</string>
<string name="ntf_channel_messages">Messages SimpleX Chat</string>
<string name="settings_notifications_mode_title">Service de notification</string>
<string name="notifications_mode_periodic">Lancer périodiquement</string>
<string name="notifications_mode_off">Exécuter lorsque lapp est ouverte</string>
<string name="notifications_mode_service">Toujours activé</string>
<string name="failed_to_parse_chat_title">Échec du chargement du chat</string>
<string name="failed_to_parse_chats_title">Échec du chargement des chats</string>
<string name="contact_developers">Veuillez mettre à jour lapp et contacter les développeurs.</string>
<string name="simplex_service_notification_text">Récupération des messages…</string>
<string name="settings_notification_preview_title">Aperçu de notification</string>
<string name="database_initialization_error_title">Échec dinitialisation de la base de données</string>
<string name="enter_passphrase_notification_desc">Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données</string>
<string name="simplex_service_notification_title">service <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="this_text_is_available_in_settings">Ce texte est disponible dans les paramètres</string>
<string name="group_preview_join_as">rejoindre en tant que %s</string>
<string name="group_preview_you_are_invited">vous êtes invité·e au groupe</string>
<string name="chat_with_developers">Discuter avec les développeurs</string>
<string name="tap_to_start_new_chat">Appuyez pour commencer un nouveau chat</string>
<string name="you_have_no_chats">Vous n\'avez aucune discussion</string>
<string name="images_limit_title">Trop dimages !</string>
<string name="share_file">Partager le fichier…</string>
<string name="attach">Attacher</string>
<string name="icon_descr_cancel_image_preview">Annuler laperçu dimage</string>
<string name="icon_descr_cancel_file_preview">Annuler laperçu du fichier</string>
<string name="icon_descr_sent_msg_status_send_failed">échec denvoi</string>
<string name="icon_descr_received_msg_status_unread">non lu</string>
<string name="welcome">Bienvenue !</string>
<string name="contact_connection_pending">connexion…</string>
<string name="group_connection_pending">connexion…</string>
<string name="share_message">Partager le message…</string>
<string name="share_image">Partager limage…</string>
<string name="images_limit_desc">Envoi de 10 images en même temps maximum</string>
<string name="personal_welcome">Bienvenue <xliff:g>%1$s</xliff:g> !</string>
<string name="notifications_mode_periodic_desc">Vérifie les nouveaux messages toutes les 10 minutes pendant 1 minute au maximum.</string>
<string name="notification_preview_mode_contact_desc">Afficher uniquement le contact</string>
<string name="for_everybody">Pour tous</string>
<string name="icon_descr_sent_msg_status_sent">envoyé</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">envoi non autorisé</string>
<string name="icon_descr_context">Icône contextuelle</string>
<string name="image_descr">Image</string>
<string name="image_decoding_exception_desc">L\'image ne peut pas être décodée. Veuillez essayer une autre image ou contacter les développeurs.</string>
<string name="icon_descr_waiting_for_image">En attente de l\'image</string>
<string name="icon_descr_asked_to_receive">Demandé à recevoir l\'image</string>
<string name="icon_descr_image_snd_complete">Image envoyée</string>
<string name="waiting_for_image">En attente de l\'image</string>
<string name="image_saved">Image enregistrée dans la phototèque</string>
<string name="icon_descr_file">Fichier</string>
<string name="large_file">Fichier trop lourd !</string>
<string name="file_saved">Fichier sauvegardé</string>
<string name="file_not_found">Fichier introuvable</string>
<string name="error_saving_file">Erreur lors de la sauvegarde du fichier</string>
<string name="delete_contact_question">Supprimer le contact \?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Le contact et tous les messages seront supprimés - impossible de revenir en arrière !</string>
<string name="button_delete_contact">Supprimer le contact</string>
<string name="icon_descr_server_status_connected">Connecté</string>
<string name="icon_descr_send_message">Envoyer un message</string>
<string name="switch_receiving_address_question">Changement d\'adresse de réception \?</string>
<string name="icon_descr_record_voice_message">Enregistrer un message vocal</string>
<string name="allow_voice_messages_question">Autoriser les messages vocaux \?</string>
<string name="you_need_to_allow_to_send_voice">Vous devez autoriser votre contact à envoyer des messages vocaux pour pouvoir en envoyer.</string>
<string name="voice_messages_prohibited">Messages vocaux interdits !</string>
<string name="ask_your_contact_to_enable_voice">Veuillez demander à votre contact de permettre l\'envoi de messages vocaux.</string>
<string name="cancel_verb">Annuler</string>
<string name="only_group_owners_can_enable_voice">Seuls les propriétaires de groupes peuvent activer les messages vocaux.</string>
<string name="back">Retour</string>
<string name="no_details">aucun détail</string>
<string name="add_contact">Lien d\'invitation unique</string>
<string name="copied">Copié dans le presse-papiers</string>
<string name="share_one_time_link">Créer un lien d\'invitation unique</string>
<string name="add_contact_or_create_group">Commencer une nouvelle discussion</string>
<string name="connect_via_link_or_qr">Se connecter via un lien / code QR</string>
<string name="scan_QR_code">Scanner un code QR</string>
<string name="to_share_with_your_contact">(à partager avec votre contact)</string>
<string name="create_group">Créer un groupe secret</string>
<string name="from_gallery_button">Depuis la Phototèque</string>
<string name="choose_file">Choisir le fichier</string>
<string name="to_start_a_new_chat_help_header">Pour démarrer une nouvelle discussion</string>
<string name="chat_help_tap_button">Appuyez sur le bouton</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scanner un code QR</b> : pour vous connecter à votre contact qui vous montre un code QR.</string>
<string name="to_connect_via_link_title">Pour se connecter via un lien</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Si vous avez reçu un lien d\'invitation <xliff:g id="appName">SimpleX Chat</xliff:g>, vous pouvez l\'ouvrir dans votre navigateur :</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 bureau : scanner le code QR affiché depuis l\'app, via <b>Scanner le code QR</b>.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile : appuyez sur <b>Ouvrir dans l\'application</b>, puis appuyez sur <b>se connecter</b> dans l\'app.</string>
<string name="accept_contact_incognito_button">Accepter en incognito</string>
<string name="reject_contact_button">Rejeter</string>
<string name="clear_chat_question">Effacer la conversation \?</string>
<string name="clear_chat_warning">Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous.</string>
<string name="clear_chat_menu_action">Effacer</string>
<string name="delete_contact_menu_action">Supprimer</string>
<string name="delete_group_menu_action">Supprimer</string>
<string name="mark_read">Marquer comme lu</string>
<string name="mark_unread">Marquer non lu</string>
<string name="set_contact_name">Définir le nom du contact</string>
<string name="you_invited_your_contact">Vous avez invité votre contact</string>
<string name="you_accepted_connection">Vous avez accepté la connexion</string>
<string name="delete_pending_connection__question">Supprimer la connexion en attente \?</string>
<string name="connection_you_accepted_will_be_cancelled">La connexion que vous avez acceptée sera annulée !</string>
<string name="alert_title_contact_connection_pending">Le contact n\'est pas encore connecté !</string>
<string name="icon_descr_close_button">Bouton fermer</string>
<string name="image_descr_profile_image">image de profil</string>
<string name="image_descr_link_preview">image d\'aperçu du lien</string>
<string name="icon_descr_cancel_link_preview">annuler l\'aperçu du lien</string>
<string name="icon_descr_settings">Paramètres</string>
<string name="icon_descr_address">Adresse <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_help">aide</string>
<string name="icon_descr_simplex_team">Équipe <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_more_button">Plus</string>
<string name="show_QR_code">Afficher le code QR</string>
<string name="invalid_QR_code">Code QR invalide</string>
<string name="this_QR_code_is_not_a_link">Ce code QR n\'est pas un lien !</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard !</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard !</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Votre contact peut scanner le code QR depuis l\'app.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Votre profil de chat sera envoyé
\nà votre contact</string>
<string name="share_invitation_link">Partager le lien d\'invitation</string>
<string name="your_profile_will_be_sent">Votre profil de chat sera envoyé à votre contact</string>
<string name="paste_button">Coller</string>
<string name="this_string_is_not_a_connection_link">Cette chaîne n\'est pas un lien de connexion !</string>
<string name="you_can_also_connect_by_clicking_the_link">Vous pouvez aussi vous connecter en cliquant sur le lien. Si il s\'ouvre dans le navigateur, cliquez sur <b>Ouvrir dans l\'app mobile</b>.</string>
<string name="create_one_time_link">Créer un lien d\'invitation unique</string>
<string name="text_field_set_contact_placeholder">Définir le nom du contact…</string>
<string name="icon_descr_server_status_disconnected">Déconnecté</string>
<string name="icon_descr_server_status_error">Erreur</string>
<string name="icon_descr_server_status_pending">En attente</string>
<string name="accept_connection_request__question">Accepter la demande de connexion \?</string>
<string name="clear_verb">Effacer</string>
<string name="clear_chat_button">Effacer la conversation</string>
<string name="paste_connection_link_below_to_connect">Collez le lien que vous avez reçu dans le cadre ci-dessous pour vous connecter avec votre contact.</string>
<string name="connect_via_link">Se connecter via un lien</string>
<string name="clear_verification">Retirer la vérification</string>
<string name="one_time_link">Lien d\'invitation unique</string>
<string name="your_contact_address">Votre adresse de contact</string>
<string name="scan_code">Scanner le code</string>
<string name="incorrect_code">Code de sécurité incorrect !</string>
<string name="security_code">Code de sécurité</string>
<string name="mark_code_verified">Marquer comme vérifié</string>
<string name="view_security_code">Afficher le code de sécurité</string>
<string name="verify_security_code">Vérifier le code de sécurité</string>
<string name="confirm_verb">Confirmer</string>
<string name="reset_verb">Réinitialisation</string>
<string name="ok">OK</string>
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(scanner ou coller depuis le presse-papiers)</string>
<string name="only_stored_on_members_devices">(uniquement stocké par les membres du groupe)</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Votre contact a besoin d\'être en ligne pour completer la connexion.
\nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien).</string>
<string name="contact_wants_to_connect_with_you">veut établir une connexion !</string>
<string name="icon_descr_profile_image_placeholder">image de profil (placeholder)</string>
<string name="image_descr_qr_code">Code QR</string>
<string name="image_descr_simplex_logo">Logo <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_email">E-mail</string>
<string name="connect_button">Se connecter</string>
<string name="notifications_mode_off_desc">L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé.</string>
<string name="notifications_mode_service_desc">Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles.</string>
<string name="notification_preview_mode_message_desc">Afficher le contact et le message</string>
<string name="notification_display_mode_hidden_desc">Masquer le contact et le message</string>
<string name="auth_log_in_using_credential">Connectez-vous en utilisant votre identifiant</string>
<string name="auth_confirm_credential">Confirmez vos identifiants</string>
<string name="auth_stop_chat">Arrêter le chat</string>
<string name="delete_message_cannot_be_undone_warning">Le message sera supprimé - impossible de revenir en arrière !</string>
<string name="delete_message_mark_deleted_warning">Le message sera marqué comme supprimé. Le·s destinataire·s pourrai·ent révéler ce message.</string>
<string name="icon_descr_edited">modifié</string>
<string name="image_will_be_received_when_contact_is_online">L\'image sera reçue quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard!</string>
<string name="image_decoding_exception_title">Erreur de décodage</string>
<string name="contact_sent_large_file">Votre contact a envoyé un fichier dont la taille est supérieure à la taille maximale actuellement prise en charge (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="waiting_for_file">En attente du fichier</string>
<string name="voice_message">Message vocal</string>
<string name="toast_permission_denied">Autorisation refusée !</string>
<string name="use_camera_button">Utiliser l\'Appareil photo</string>
<string name="thank_you_for_installing_simplex">Merci d\'avoir installé <xliff:g id="appNameFull">SimpleX Chat</xliff:g> !</string>
<string name="you_can_connect_to_simplex_chat_founder">Vous pouvez <font color="#0088ff">vous connecter aux développeurs de <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour leur poser des questions et recevoir des réponses :</font>.</string>
<string name="above_then_preposition_continuation">ci-dessus, puis :</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Ajouter un nouveau contact</b> : afin de créer un code QR à usage unique pour votre contact.</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Si vous choisissez de la rejeter, l\'expéditeur·rice NE sera PAS notifié·e.</string>
<string name="accept_contact_button">Accepter</string>
<string name="mute_chat">Muet</string>
<string name="unmute_chat">Démute</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter !</string>
<string name="invalid_contact_link">Lien invalide !</string>
<string name="this_link_is_not_a_valid_connection_link">Ce lien n\'est pas un lien de connexion valide !</string>
<string name="connection_request_sent">Demande de connexion envoyée !</string>
<string name="file_will_be_received_when_contact_is_online">Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard!</string>
<string name="voice_message_send_text">Message vocal…</string>
<string name="maximum_supported_file_size">La taille maximale supportés des fichiers actuellement est de <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="voice_message_with_duration">Message vocal (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="notifications">Notifications</string>
<string name="switch_receiving_address_desc">Cette fonctionnalité est expérimentale ! Elle ne fonctionnera que si l\'autre client a la version 4.2 installée. Vous devriez voir le message dans la conversation une fois le changement d\'adresse effectué. Vérifiez que vous pouvez toujours recevoir des messages de ce contact (ou membre du groupe).</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Vous serez connecté·e au groupe lorsque l\'appareil de l\'hôte sera en ligne, veuillez attendre ou vérifier plus tard !</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Si vous ne pouvez pas vous rencontrer en personne, <b>montrez le code QR lors d\'un appel vidéo</b>, ou partagez le lien.</string>
<string name="scan_code_from_contacts_app">Scannez le code de sécurité depuis l\'application de votre contact.</string>
<string name="to_verify_compare">Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils.</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Si vous ne pouvez pas vous rencontrer en personne, vous pouvez <b>scanner un code QR lors d\'un appel vidéo</b>, ou votre contact peut partager un lien d\'invitation.</string>
<string name="smp_servers_add">Ajouter un serveur…</string>
<string name="markdown_in_messages">Markdown dans les messages</string>
<string name="smp_servers_preset_add">Ajouter des serveurs prédéfinis</string>
<string name="use_simplex_chat_servers__question">Utiliser les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g> \?</string>
<string name="smp_servers_delete_server">Supprimer le serveur</string>
<string name="ensure_ICE_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne.</string>
<string name="network_enable_socks_info">Accéder aux serveurs via un proxy SOCKS sur le port 9050 \? Le proxy doit être démarré avant d\'activer cette option.</string>
<string name="network_use_onion_hosts">Utiliser les hôtes .onions</string>
<string name="network_use_onion_hosts_prefer_desc_in_alert">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Les hôtes .onion seront nécessaires pour la connexion.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Vous contrôlez par quel·s serveur·s vous pouvez <b>transmettre</b> ainsi que par quel·s serveur·s vous pouvez <b>recevoir</b> les messages de vos contacts.</string>
<string name="your_settings">Vos paramètres</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Console du chat</string>
<string name="smp_servers">Serveurs SMP</string>
<string name="smp_servers_test_servers">Tester les serveurs</string>
<string name="smp_servers_save">Sauvegarder les serveurs</string>
<string name="smp_servers_scan_qr">Scanner un code QR de serveur</string>
<string name="smp_servers_use_server">Utiliser ce serveur</string>
<string name="smp_servers_use_server_for_new_conn">Utiliser pour les nouvelles connexions</string>
<string name="smp_servers_add_to_another_device">Ajouter à un autre appareil</string>
<string name="install_simplex_chat_for_terminal">Installer <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour terminal</string>
<string name="star_on_github">Star sur GitHub</string>
<string name="contribute">Contribuer</string>
<string name="rate_the_app">Évaluer l\'app</string>
<string name="your_SMP_servers">Vos serveurs SMP</string>
<string name="how_to_use_your_servers">Comment utiliser vos serveurs</string>
<string name="saved_ICE_servers_will_be_removed">Les serveurs WebRTC ICE sauvegardés seront supprimés.</string>
<string name="your_ICE_servers">Vos serveurs ICE</string>
<string name="configure_ICE_servers">Configurer les serveurs ICE</string>
<string name="network_settings">Paramètres réseau avancés</string>
<string name="network_settings_title">Paramètres réseau</string>
<string name="network_socks_toggle">Utiliser un proxy SOCKS (port 9050)</string>
<string name="network_enable_socks">Utiliser un proxy SOCKS \?</string>
<string name="network_disable_socks">Utiliser une connexion Internet directe \?</string>
<string name="network_disable_socks_info">Si vous confirmez, les serveurs de messagerie seront en mesure de voir votre adresse IP, votre fournisseur ainsi que les serveurs auxquels vous vous connectez.</string>
<string name="network_use_onion_hosts_no">Non</string>
<string name="network_use_onion_hosts_required">Requis</string>
<string name="network_use_onion_hosts_prefer_desc">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
<string name="appearance_settings">Apparence</string>
<string name="create_address">Créer une adresse</string>
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Vous pouvez partager votre adresse sous forme de lien ou de code QR - n\'importe qui pourra se connecter à vous. Vous ne perdrez pas vos contacts si vous la supprimez par la suite.</string>
<string name="your_chat_profile">Votre profil de chat</string>
<string name="edit_image">Modifier l\'image</string>
<string name="save_and_notify_contacts">Sauvegarder et notifier les contacts</string>
<string name="save_and_notify_group_members">Sauvegarder et en informer les membres du groupe</string>
<string name="your_profile_is_stored_on_your_device">Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil.</string>
<string name="profile_is_only_shared_with_your_contacts">Le profil n\'est partagé qu\'avec vos contacts.</string>
<string name="display_name_cannot_contain_whitespace">Le nom d\'affichage ne peut pas contenir d\'espace.</string>
<string name="full_name_optional__prompt">Nom complet (optionnel)</string>
<string name="create_profile_button">Créer</string>
<string name="about_simplex">À propos de SimpleX</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Vous pouvez utiliser le format markdown pour mettre en forme les messages :</string>
<string name="bold">gras</string>
<string name="italic">italique</string>
<string name="strikethrough">barré</string>
<string name="callstatus_accepted">appel accepté</string>
<string name="callstatus_connecting">connexion à l\'appel…</string>
<string name="callstatus_error">erreur d\'appel</string>
<string name="callstate_received_answer">réponse reçu…</string>
<string name="callstate_received_confirmation">confimation reçu…</string>
<string name="callstate_connecting">connexion…</string>
<string name="opensource_protocol_and_code_anybody_can_run_servers">Protocole et code open-source n\'importe qui peut heberger un serveur.</string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Pour protéger votre vie privée, au lieu d\'IDs utilisés par toutes les autres plateformes, <xliff:g id="appName">SimpleX</xliff:g> possède des IDs pour les queues de messages, distinctes pour chacun de vos contacts.</string>
<string name="read_more_in_github">Plus d\'informations sur notre GitHub.</string>
<string name="paste_the_link_you_received">Coller le lien reçu</string>
<string name="use_chat">Utiliser le chat</string>
<string name="onboarding_notifications_mode_title">Notifications privées</string>
<string name="onboarding_notifications_mode_subtitle">Peut être modifié ultérieurement via les paramètres.</string>
<string name="onboarding_notifications_mode_off">Quand l\'application fonctionne</string>
<string name="onboarding_notifications_mode_periodic">Périodique</string>
<string name="onboarding_notifications_mode_service">Instantanée</string>
<string name="onboarding_notifications_mode_off_desc"><b>Économie de batterie</b>. Vous recevrez des notifications uniquement lorsque l\'application est en cours d\'exécution, le service de fond ne sera PAS utilisé.</string>
<string name="about_simplex_chat">À propos de <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">Comment l\'utiliser</string>
<string name="markdown_help">Aide Markdown</string>
<string name="save_servers_button">Sauvegarder</string>
<string name="network_and_servers">Réseau et serveurs</string>
<string name="save_and_notify_contact">Sauvegarder et en informer les contacts</string>
<string name="exit_without_saving">Quitter sans sauvegarder</string>
<string name="callstatus_rejected">appel rejeté</string>
<string name="callstatus_in_progress">appel en cours</string>
<string name="callstatus_ended">appel terminé <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstate_starting">lancement…</string>
<string name="is_verified">%s est vérifié·e</string>
<string name="is_not_verified">%s n\'est pas vérifié·e</string>
<string name="your_simplex_contact_address">Votre adresse de contact <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="database_passphrase_and_export">Phrase secrète et exportation de la base de données</string>
<string name="chat_with_the_founder">Envoyez vos questions et idées</string>
<string name="send_us_an_email">Envoyez nous un e-mail</string>
<string name="smp_servers_preset_address">Adresse du serveur prédéfinie</string>
<string name="smp_servers_test_server">Tester le serveur</string>
<string name="smp_servers_test_failed">Échec du test du serveur !</string>
<string name="smp_servers_test_some_failed">Certains serveurs n\'ont pas réussi le test :</string>
<string name="smp_servers_enter_manually">Entrer un serveur manuellement</string>
<string name="smp_servers_preset_server">Serveur prédéfini</string>
<string name="smp_servers_your_server">Votre serveur</string>
<string name="smp_servers_your_server_address">Votre adresse de serveur</string>
<string name="smp_servers_invalid_address">Adresse de serveur invalide !</string>
<string name="smp_servers_check_address">Vérifiez l\'adresse du serveur et réessayez.</string>
<string name="using_simplex_chat_servers">Utilise les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="how_to">Comment faire</string>
<string name="enter_one_ICE_server_per_line">Serveurs ICE (un par ligne)</string>
<string name="error_saving_ICE_servers">Erreur lors de la sauvegarde des serveurs ICE</string>
<string name="update_onion_hosts_settings_question">Mettre à jour le paramètre des hôtes .onion \?</string>
<string name="network_use_onion_hosts_prefer">Quand disponible</string>
<string name="network_use_onion_hosts_no_desc">Les hôtes .onion ne seront pas utilisés.</string>
<string name="network_use_onion_hosts_required_desc">Les hôtes .onion seront nécessaires pour la connexion.</string>
<string name="network_use_onion_hosts_no_desc_in_alert">Les hôtes .onion ne seront pas utilisés.</string>
<string name="delete_address__question">Supprimer l\'adresse \?</string>
<string name="all_your_contacts_will_remain_connected">Tous vos contacts resteront connectés.</string>
<string name="share_link">Partager le lien</string>
<string name="delete_address">Supprimer l\'adresse</string>
<string name="contact_requests">Demandes de contact</string>
<string name="accept_requests">Accepter les demandes</string>
<string name="accept_automatically">Automatiquement</string>
<string name="section_title_welcome_message">MESSAGE DE BIENVENUE</string>
<string name="display_name__field">Nom affiché :</string>
<string name="full_name__field">Nom complet :</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts.
\n
\nLes serveurs <xliff:g id="appName">SimpleX</xliff:g> ne peuvent pas voir votre profil.</string>
<string name="delete_image">Supprimer l\'image</string>
<string name="save_preferences_question">Sauvegarder les préférences \?</string>
<string name="you_control_your_chat">Vous maîtrisez vos discussions !</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité.</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs.</string>
<string name="create_profile">Créer le profil</string>
<string name="display_name">Nom affiché</string>
<string name="how_to_use_markdown">Comment utiliser markdown</string>
<string name="a_plus_b">a + b</string>
<string name="colored">coloré</string>
<string name="secret">secret</string>
<string name="callstatus_calling">appel…</string>
<string name="callstatus_missed">appel manqué</string>
<string name="callstate_waiting_for_answer">en attente de réponse…</string>
<string name="callstate_waiting_for_confirmation">en attente de confirmation…</string>
<string name="callstate_connected">connecté</string>
<string name="callstate_ended">terminé</string>
<string name="next_generation_of_private_messaging">La nouvelle génération de messagerie privée</string>
<string name="privacy_redefined">La vie privée redéfinie</string>
<string name="first_platform_without_user_ids">La 1ère plateforme sans aucun identifiant d\'utilisateur privée par design.</string>
<string name="immune_to_spam_and_abuse">Protégé du spam et des abus</string>
<string name="people_can_connect_only_via_links_you_share">On ne peut se connecter à vous quavec les liens que vous partagez.</string>
<string name="decentralized">Décentralisé</string>
<string name="create_your_profile">Créez votre profil</string>
<string name="make_private_connection">Établir une connexion privée</string>
<string name="how_it_works">Comment ça fonctionne</string>
<string name="how_simplex_works">Comment <xliff:g id="appName">SimpleX</xliff:g> fonctionne</string>
<string name="many_people_asked_how_can_it_deliver">Beaucoup se demandent : <i>si <xliff:g id="appName">SimpleX</xliff:g> n\'a pas d\'identifiant d\'utilisateur, comment peut-il transmettre des messages \?</i></string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un <b>chiffrement de bout en bout à deux couches</b>.</string>
<string name="read_more_in_github_with_link">Pour en savoir plus, consultez notre <font color="#0088ff">GitHub repository</font>.</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Batterie peu utilisée</b>. Le service de fond vérifie les nouveaux messages toutes les 10 minutes. Vous risquez de manquer des appels et des messages urgents.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Batterie plus utilisée </b> ! Le service de fond est toujours en cours d\'exécution - les notifications s\'afficheront dès que les messages seront disponibles.</string>
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> message⸱s manqué⸱s</string>
<string name="integrity_msg_bad_id">ID de message incorrecte</string>
<string name="settings_section_title_settings">PARAMÈTRES</string>
<string name="alert_text_skipped_messages_it_can_happen_when">C\'est possible quand :
\n1. Les messages expirent du serveur (après 30 jours si ils ne sont pas reçu).
\n2. Le serveur que vous utilisez pour recevoir les messages de ce contact a été mise à jour ou redémarré.
\n3. La connection est compromise.
\nVeuillez vous connecter aux développeurs via les Paramètres pour recevoir les mises à jour concernant les serveurs.
\nNous allons ajouter une redondance des serveurs pour éviter la perte de messages.</string>
<string name="icon_descr_call_rejected">Appel rejeté</string>
<string name="rcv_group_event_member_deleted">a retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_member_deleted">vous avez retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_invited_via_your_group_link">invité par votre lien de groupe</string>
<string name="snd_conn_event_switch_queue_phase_completed">vous avez changé d\'adresse</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Arrêtez le chat pour exporter, importer ou supprimer la base de données du chat. Vous ne pourrez pas recevoir et envoyer de messages pendant que le chat est arrêté.</string>
<string name="enable_automatic_deletion_message">Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes.</string>
<string name="encrypted_with_random_passphrase">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire, que vous pouvez modifier.</string>
<string name="restore_database">Restaurer la sauvegarde de la base de données</string>
<string name="restore_passphrase_not_found_desc">La phrase secrète n\'a pas été trouvée dans le Keystore, veuillez la saisir manuellement. Cela a pu se produire si vous avez restauré les données de l\'app à l\'aide d\'un outil de sauvegarde. Si ce n\'est pas le cas, veuillez contacter les développeurs.</string>
<string name="restore_database_alert_desc">Veuillez entrer le mot de passe précédent après avoir restauré la sauvegarde de la base de données. Cette action ne peut pas être annulée.</string>
<string name="database_restore_error">Erreur de restauration de la base de données</string>
<string name="archive_created_on_ts">Créé le <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="encrypted_video_call">appel vidéo (chiffrement de bout en bout)</string>
<string name="audio_call_no_encryption">appel audio (sans chiffrement)</string>
<string name="encrypted_audio_call">appel audio (chiffrement de bout en bout)</string>
<string name="accept">Accepter</string>
<string name="reject">Rejeter</string>
<string name="icon_descr_video_call">appel vidéo</string>
<string name="icon_descr_audio_call">appel audio</string>
<string name="accept_call_on_lock_screen">Accepter</string>
<string name="allow_accepting_calls_from_lock_screen">Activer les appels depuis l\'écran verrouillé via les Paramètres.</string>
<string name="open_verb">Ouvrir</string>
<string name="call_connection_via_relay">via relais</string>
<string name="icon_descr_hang_up">Raccrocher</string>
<string name="icon_descr_video_on">Vidéo ON</string>
<string name="icon_descr_video_off">Vidéo OFF</string>
<string name="icon_descr_call_progress">Appel en cours</string>
<string name="icon_descr_call_ended">Appel terminé</string>
<string name="your_privacy">Votre vie privée</string>
<string name="settings_section_title_device">APPAREIL</string>
<string name="settings_section_title_chats">CHATS</string>
<string name="settings_developer_tools">Outils du développeur</string>
<string name="settings_section_title_icon">ICONE DE L\'APP</string>
<string name="your_chat_database">Votre base de données de chat</string>
<string name="run_chat_section">LANCER LE CHAT</string>
<string name="stop_chat_question">Arrêter le chat \?</string>
<string name="restart_the_app_to_use_imported_chat_database">Redémarrez l\'application pour utiliser la base de données de chat importée.</string>
<string name="data_section">DONNÉES</string>
<string name="chat_item_ttl_day">1 jour</string>
<string name="delete_messages">Supprimer les messages</string>
<string name="save_passphrase_in_keychain">Sauvegarder la phrase secrète dans le keystore</string>
<string name="database_encrypted">Base de données chiffrée !</string>
<string name="error_encrypting_database">Erreur lors du chiffrement de la base de données</string>
<string name="update_database">Mise à jour</string>
<string name="encrypt_database">Chiffrer</string>
<string name="enter_correct_current_passphrase">Veuillez entrer la phrase secrète actuelle correcte.</string>
<string name="database_is_not_encrypted">Votre base de données de chat n\'est pas chiffrée - définissez une phrase secrète pour la protéger.</string>
<string name="impossible_to_recover_passphrase"><b>Veuillez noter</b> : vous NE pourrez PAS récupérer ou modifier la phrase secrète si vous la perdez.</string>
<string name="keychain_allows_to_receive_ntfs">Le Keystore d\'Android sera utilisé pour stocker en toute sécurité la phrase secrète après sa modification ou redémarrage de l\'app - cela permettra de recevoir les notifications.</string>
<string name="store_passphrase_securely_without_recover">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez.</string>
<string name="passphrase_is_different">La phrase secrète de la base de données est différente de celle enregistrée dans le Keystore.</string>
<string name="unknown_error">Erreur inconnue</string>
<string name="enter_correct_passphrase">Entrez la phrase secrète correcte.</string>
<string name="alert_message_no_group">Ce groupe n\'existe plus.</string>
<string name="you_joined_this_group">Vous avez rejoint ce groupe</string>
<string name="you_rejected_group_invitation">Vous avez rejeté l\'invitation du groupe</string>
<string name="snd_conn_event_switch_queue_phase_completed_for_member">vous avez changé d\'adresse pour %s</string>
<string name="rcv_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
<string name="incoming_video_call">Appel vidéo entrant</string>
<string name="video_call_no_encryption">appel vidéo (sans chiffrement)</string>
<string name="ignore">Ignorer</string>
<string name="call_already_ended">Appel déjà terminé !</string>
<string name="settings_audio_video_calls">Appels audio et vidéo</string>
<string name="status_e2e_encrypted">chiffré de bout en bout</string>
<string name="settings_section_title_develop">DEVELOPPER</string>
<string name="settings_experimental_features">Fonctionnalités expérimentales</string>
<string name="settings_section_title_socks">SOCKS PROXY</string>
<string name="settings_section_title_themes">THEMES</string>
<string name="settings_section_title_messages">MESSAGES</string>
<string name="settings_section_title_calls">APPELS</string>
<string name="import_database">Importer la base de données</string>
<string name="new_database_archive">Nouvelle archive de base de données</string>
<string name="old_database_archive">Archives de l\'ancienne base de données</string>
<string name="delete_database">Supprimer la base de données</string>
<string name="error_starting_chat">Erreur lors du démarrage du chat</string>
<string name="import_database_confirmation">Importer</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus.</string>
<string name="chat_database_deleted">Base de données du chat supprimée</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Redémarrez l\'application pour créer un nouveau profil de chat.</string>
<string name="you_must_use_the_most_recent_version_of_database">Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts.</string>
<string name="total_files_count_and_size">%d fichier·s avec une taille totale de %s</string>
<string name="chat_item_ttl_none">jamais</string>
<string name="chat_item_ttl_week">1 semaine</string>
<string name="database_will_be_encrypted_and_passphrase_stored">La base de données sera chiffrée et la phrase secrète sera stockée dans le Keystore.</string>
<string name="database_encryption_will_be_updated">La phrase secrète de la base de données sera mise à jour et stockée dans le Keystore.</string>
<string name="database_passphrase_will_be_updated">La phrase secrète de la base de données sera mise à jour.</string>
<string name="store_passphrase_securely">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez.</string>
<string name="wrong_passphrase">Mauvaise phrase secrète pour la base de données</string>
<string name="encrypted_database">Base de données chiffrée</string>
<string name="database_error">Erreur de base de données</string>
<string name="error_with_info">Erreur : %s</string>
<string name="cannot_access_keychain">Impossible d\'accéder au Keystore pour enregistrer le mot de passe de la base de données</string>
<string name="unknown_database_error_with_info">Erreur de base de données inconnue : %s</string>
<string name="wrong_passphrase_title">Mauvaise phrase secrète !</string>
<string name="leave_group_question">Quitter le groupe \?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Vous ne recevrez plus de messages de ce groupe. L\'historique du chat sera conservé.</string>
<string name="icon_descr_add_members">Inviter des membres</string>
<string name="icon_descr_group_inactive">Groupe inactif</string>
<string name="alert_title_group_invitation_expired">Invitation expirée !</string>
<string name="alert_message_group_invitation_expired">L\'invitation du groupe n\'est plus valide, elle a été supprimé par l\'expéditeur.</string>
<string name="alert_title_no_group">Groupe introuvable !</string>
<string name="alert_title_cant_invite_contacts">Impossible d\'inviter les contacts !</string>
<string name="alert_title_cant_invite_contacts_descr">Vous utilisez un profil incognito pour ce groupe - pour éviter de partager votre profil principal ; inviter des contacts n\'est pas possible</string>
<string name="you_sent_group_invitation">Vous avez envoyé une invitation de groupe</string>
<string name="rcv_group_event_member_left">a quitté</string>
<string name="icon_descr_speaker_on">Haut-parleur ON</string>
<string name="send_link_previews">Envoi d\'aperçus de liens</string>
<string name="error_deleting_database">Erreur lors de la suppression de la base de données du chat</string>
<string name="error_stopping_chat">Erreur lors de l\'arrêt du chat</string>
<string name="error_exporting_chat_database">Erreur lors de l\'exportation de la base de données du chat</string>
<string name="import_database_question">Importer la base de données du chat \?</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Votre base de données de chat actuelle sera SUPPRIMÉE et REMPLACÉE par celle qui a été importée.
\nCette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irrémédiablement perdus.</string>
<string name="enter_passphrase">Entrez la phrase secrète…</string>
<string name="incoming_audio_call">Appel audio entrant</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> veut se connecter à vous via</string>
<string name="your_calls">Vos appels</string>
<string name="connect_calls_via_relay">Se connecter via relais</string>
<string name="call_on_lock_screen">Appels en écran verrouillé :</string>
<string name="show_call_on_lock_screen">Montrer</string>
<string name="no_call_on_lock_screen">Désactiver</string>
<string name="your_ice_servers">Vos serveurs ICE</string>
<string name="webrtc_ice_servers">Serveurs WebRTC ICE</string>
<string name="open_simplex_chat_to_accept_call">Ouvrez <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour décrocher</string>
<string name="status_no_e2e_encryption">sans chiffrement de bout en bout</string>
<string name="status_contact_has_e2e_encryption">Ce contact a le chiffrement de bout en bout</string>
<string name="status_contact_has_no_e2e_encryption">Ce contact n\'a pas le chiffrement de bout en bout</string>
<string name="call_connection_peer_to_peer">pair-à-pair</string>
<string name="icon_descr_audio_off">Audio OFF</string>
<string name="icon_descr_audio_on">Audio ON</string>
<string name="icon_descr_speaker_off">Haut-parleur OFF</string>
<string name="icon_descr_flip_camera">Retourner la caméra</string>
<string name="icon_descr_call_pending_sent">Appel en suspend</string>
<string name="icon_descr_call_missed">Appel manqué</string>
<string name="icon_descr_call_connecting">Appel en connexion</string>
<string name="answer_call">Répondre à l\'appel</string>
<string name="integrity_msg_bad_hash">hash de message incorrect</string>
<string name="integrity_msg_duplicate">message dupliqué</string>
<string name="alert_title_skipped_messages">Messages manqués</string>
<string name="privacy_and_security">Vie privée et sécurité</string>
<string name="protect_app_screen">Protéger l\'écran de l\'app</string>
<string name="auto_accept_images">Images auto-acceptées</string>
<string name="transfer_images_faster">Transfert d\'images plus rapide</string>
<string name="full_backup">Sauvegarde des données de l\'app</string>
<string name="settings_section_title_you">VOUS</string>
<string name="settings_section_title_help">AIDE</string>
<string name="settings_section_title_support">SOUTENEZ SIMPLEX CHAT</string>
<string name="settings_section_title_incognito">Mode Incognito</string>
<string name="chat_is_running">Le chat est en cours d\'exécution</string>
<string name="chat_is_stopped">Le chat est arrêté</string>
<string name="chat_database_section">BASE DE DONNÉES DU CHAT</string>
<string name="database_passphrase">Phrase secrète de la base de données</string>
<string name="export_database">Exporter la base de données</string>
<string name="stop_chat_confirmation">Arrêter</string>
<string name="set_password_to_export">Définir la phrase secrète pour l\'export</string>
<string name="set_password_to_export_desc">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire. Veuillez la changer avant d\'exporter.</string>
<string name="error_importing_database">Erreur lors de l\'importation de la base de données du chat</string>
<string name="chat_database_imported">Base de données du chat importée</string>
<string name="delete_chat_profile_question">Supprimer le profil du chat \?</string>
<string name="stop_chat_to_enable_database_actions">Arrêter le chat pour agir sur la base de données.</string>
<string name="delete_files_and_media_question">Supprimer les fichiers et médias \?</string>
<string name="delete_files_and_media">"Supprimer les fichiers médias"</string>
<string name="delete_files_and_media_desc">Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées.</string>
<string name="no_received_app_files">Aucun fichier reçu ou envoyé</string>
<string name="chat_item_ttl_month">1 mois</string>
<string name="chat_item_ttl_seconds">%s seconde·s</string>
<string name="delete_messages_after">Supprimer les messages après</string>
<string name="enable_automatic_deletion_question">Activer la suppression automatique des messages \?</string>
<string name="error_changing_message_deletion">Erreur de changement de paramètre</string>
<string name="remove_passphrase_from_keychain">Retirer la phrase secrète du Keystore \?</string>
<string name="notifications_will_be_hidden">Les notifications seront délivrées jusqu\'à ce que l\'application s\'arrête !</string>
<string name="remove_passphrase">Supprimer</string>
<string name="current_passphrase">Phrase secrète actuelle…</string>
<string name="new_passphrase">Nouvelle phrase secrète…</string>
<string name="confirm_new_passphrase">Confirmer la nouvelle phrase secrète…</string>
<string name="update_database_passphrase">Mise à jour de la phrase secrète de la base de données</string>
<string name="keychain_is_storing_securely">Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
<string name="you_have_to_enter_passphrase_every_time">Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil.</string>
<string name="encrypt_database_question">Chiffrer la base de données \?</string>
<string name="change_database_passphrase_question">Changer la phrase secrète de la base de données \?</string>
<string name="database_will_be_encrypted">La base de données sera chiffrée.</string>
<string name="keychain_error">Erreur de la keychain</string>
<string name="file_with_path">Fichier : %s</string>
<string name="database_passphrase_is_required">La phrase secrète de la base de données est nécessaire pour ouvrir le chat.</string>
<string name="save_passphrase_and_open_chat">Sauvegarder la phrase secrète et ouvrir le chat</string>
<string name="open_chat">Ouvrir le chat</string>
<string name="database_backup_can_be_restored">La tentative de modification de la phrase secrète de la base de données n\'a pas abouti.</string>
<string name="restore_database_alert_title">Restaurer la sauvegarde de la base de données \?</string>
<string name="restore_database_alert_confirm">Restaurer</string>
<string name="chat_is_stopped_indication">Le chat est arrêté</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Vous pouvez lancer le chat via les Paramètres / la Base de données de l\'app ou en la redémarrant.</string>
<string name="chat_archive_header">Archives du chat</string>
<string name="chat_archive_section">ARCHIVE DU CHAT</string>
<string name="save_archive">Sauvegarder l\'archive</string>
<string name="delete_archive">Supprimer l\'archive</string>
<string name="delete_chat_archive_question">Supprimer l\'archive du chat \?</string>
<string name="group_invitation_item_description">Invitation au groupe <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Rejoindre le groupe \?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Vous êtes invité·e dans un groupe. Rejoignez le pour vous connecter avec ses membres.</string>
<string name="join_group_button">Rejoindre</string>
<string name="join_group_incognito_button">Rejoindre en incognito</string>
<string name="joining_group">Entrain de rejoindre le groupe</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Vous avez rejoint ce groupe. Connexion à l\'invitation d\'un membre du groupe.</string>
<string name="leave_group_button">Quitter</string>
<string name="you_are_invited_to_group">Vous êtes invité·e au groupe</string>
<string name="group_invitation_tap_to_join">Appuyez pour rejoindre</string>
<string name="group_invitation_tap_to_join_incognito">Appuyez pour rejoindre incognito</string>
<string name="group_invitation_expired">Invitation au groupe expirée</string>
<string name="rcv_group_event_member_added">a invité <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">est connecté·e</string>
<string name="rcv_group_event_changed_member_role">a modifié le rôle de %s pour %s</string>
<string name="rcv_group_event_changed_your_role">a modifié votre rôle pour %s</string>
<string name="rcv_group_event_user_deleted">vous a retiré</string>
<string name="rcv_group_event_group_deleted">a supprimé le groupe</string>
<string name="rcv_group_event_updated_group_profile">mise à jour du profil de groupe</string>
<string name="snd_group_event_changed_member_role">vous avez modifié le rôle de %s pour %s</string>
<string name="snd_group_event_changed_role_for_yourself">vous avez modifié votre rôle pour %s</string>
<string name="snd_group_event_user_left">vous avez quitté</string>
<string name="snd_group_event_group_profile_updated">mise à jour du profil de groupe</string>
<string name="rcv_conn_event_switch_queue_phase_completed">adresse modifiée pour vous</string>
<string name="snd_conn_event_switch_queue_phase_changing_for_member">changement d\'adresse pour %s…</string>
<string name="snd_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
<string name="group_member_role_member">membre</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">propriétaire</string>
<string name="group_member_status_removed">supprimé</string>
<string name="group_member_status_left">a quitté</string>
<string name="group_member_status_group_deleted">groupe supprimé</string>
<string name="group_member_status_invited">invité·e</string>
<string name="group_member_status_introduced">connexion (introduite)</string>
<string name="group_member_status_intro_invitation">connexion (introduite par invitation)</string>
<string name="group_member_status_accepted">connexion (acceptée)</string>
<string name="group_member_status_announced">connexion (annoncée)</string>
<string name="group_member_status_connected">connecté</string>
<string name="group_member_status_complete">complet</string>
<string name="group_member_status_creator">créateur</string>
<string name="group_member_status_connecting">connexion</string>
<string name="no_contacts_to_add">Aucun contact à ajouter</string>
<string name="new_member_role">Nouveau rôle</string>
<string name="delete_group_question">Supprimer le groupe\?</string>
<string name="group_link">Lien du groupe</string>
<string name="button_create_group_link">Créer un lien</string>
<string name="button_edit_group_profile">Modifier le profil du groupe</string>
<string name="remove_member_confirmation">Supprimer</string>
<string name="member_info_section_title_member">MEMBRE</string>
<string name="live_message">Message dynamique !</string>
<string name="send_live_message">Envoyer un message dynamique</string>
<string name="send_live_message_desc">Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez</string>
<string name="send_verb">Envoyer</string>
<string name="member_role_will_be_changed_with_invitation">Le rôle sera changé pour «%s». Le membre va recevoir une nouvelle invitation.</string>
<string name="live">LIVE</string>
<string name="button_add_members">Inviter des membres</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Vous pouvez partager un lien ou un code QR - n\'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite.</string>
<string name="info_row_local_name">Nom local</string>
<string name="create_group_link">Créer un lien de groupe</string>
<string name="error_deleting_link_for_group">Erreur lors de la suppression du lien du groupe</string>
<string name="error_creating_link_for_group">Erreur lors de la création du lien du groupe</string>
<string name="only_group_owners_can_change_prefs">Seuls les propriétaires du groupe peuvent modifier les préférences du groupe.</string>
<string name="section_title_for_console">POUR TERMINAL</string>
<string name="change_member_role_question">Changer le rôle du groupe \?</string>
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour «%s». Les membres du groupe seront notifiés.</string>
<string name="icon_descr_contact_checked">Contact vérifié⸱e</string>
<string name="clear_contacts_selection_button">Effacer</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact·s sélectionné·e·s</string>
<string name="skip_inviting_button">Passer linvitation de membres</string>
<string name="select_contacts">Sélectionnez des contacts</string>
<string name="no_contacts_selected">Aucun contact sélectionné</string>
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRES</string>
<string name="group_info_member_you">vous : <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="button_delete_group">Supprimer le groupe</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière!</string>
<string name="delete_group_for_self_cannot_undo_warning">Le groupe va être supprimé pour vous - impossible de revenir en arrière!</string>
<string name="button_leave_group">Quitter le groupe</string>
<string name="delete_link_question">Supprimer le lien\?</string>
<string name="delete_link">Supprimer le lien</string>
<string name="all_group_members_will_remain_connected">Tous les membres du groupe resteront connectés.</string>
<string name="icon_descr_expand_role">Étendre la sélection de rôle</string>
<string name="invite_to_group_button">Inviter au groupe</string>
<string name="invite_prohibited">Impossible d\'inviter le contact!</string>
<string name="invite_prohibited_description">Vous essayez d\'inviter un contact avec lequel vous avez partagé un profil incognito à rejoindre le groupe dans lequel vous utilisez votre profil principal</string>
<string name="info_row_database_id">ID de base de données</string>
<string name="button_remove_member">Retirer le membre</string>
<string name="button_send_direct_message">Envoi de message direct</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Ce membre sera retiré du groupe - impossible de revenir en arrière!</string>
<string name="role_in_group">Rôle</string>
<string name="change_role">Changer le rôle</string>
<string name="change_verb">Changer</string>
<string name="switch_verb">Échanger</string>
<string name="error_removing_member">Erreur lors de la suppression d\'un membre</string>
<string name="error_changing_role">Erreur lors du changement de rôle</string>
<string name="group_full_name_field">Nom complet du groupe :</string>
<string name="update_network_settings_confirmation">Mise à jour</string>
<string name="chat_preferences_on">on</string>
<string name="chat_preferences_off">off</string>
<string name="direct_messages">Messages dynamiques</string>
<string name="full_deletion">Supprimer pour tous</string>
<string name="only_you_can_delete_messages">Vous êtes le seul à pouvoir supprimer des messages de manière irréversible (votre contact peut les marquer pour suppression).</string>
<string name="conn_stats_section_title_servers">SERVEURS</string>
<string name="receiving_via">Réception via</string>
<string name="theme_system">Système</string>
<string name="allow_direct_messages">Autoriser l\'envoi de messages directs aux membres.</string>
<string name="prohibit_direct_messages">Interdire l\'envoi de messages directs aux membres.</string>
<string name="group_members_can_delete">Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés.</string>
<string name="message_deletion_prohibited_in_chat">La suppression irréversible de messages est interdite dans ce groupe.</string>
<string name="sending_via">Envoi via</string>
<string name="network_status">État du réseau</string>
<string name="switch_receiving_address">Changer d\'adresse de réception</string>
<string name="create_secret_group_title">Créer un groupe secret</string>
<string name="group_main_profile_sent">Votre profil de chat sera envoyé aux membres du groupe</string>
<string name="network_option_enable_tcp_keep_alive">Activer le TCP keep-alive</string>
<string name="network_options_save">Sauvegarder</string>
<string name="update_network_settings_question">Mettre à jour les paramètres réseau \?</string>
<string name="incognito">Incognito</string>
<string name="incognito_random_profile">Votre profil aléatoire</string>
<string name="incognito_random_profile_description">Un profil aléatoire sera envoyé à votre contact</string>
<string name="incognito_random_profile_from_contact_description">Un profil aléatoire sera envoyé au contact qui vous a envoyé ce lien</string>
<string name="incognito_info_allows">Cela permet d\'avoir plusieurs connections anonymes sans aucune données partagées entre elles sur un même profil.</string>
<string name="incognito_info_find">Pour trouver le profil utilisé lors d\'une connexion incognito, appuyez sur le nom du contact ou du groupe en haut du chat.</string>
<string name="theme_light">Clair</string>
<string name="theme_dark">Sombre</string>
<string name="theme">Thème</string>
<string name="save_color">Sauvegarder la couleur</string>
<string name="reset_color">Réinitialisation des couleurs</string>
<string name="color_primary">Principale</string>
<string name="chat_preferences_you_allow">Vous autorisez</string>
<string name="chat_preferences_contact_allows">Votre contact autorise</string>
<string name="chat_preferences_default">par défaut (%s)</string>
<string name="chat_preferences_no">non</string>
<string name="chat_preferences_always">toujours</string>
<string name="chat_preferences">Préférences de chat</string>
<string name="contact_preferences">Préférences de contact</string>
<string name="group_preferences">Préférences du groupe</string>
<string name="set_group_preferences">Définir les préférences du groupe</string>
<string name="your_preferences">Vos préférences</string>
<string name="allow_your_contacts_irreversibly_delete">Autorise votre contact à supprimer de façon définitive des messages envoyés.</string>
<string name="contacts_can_mark_messages_for_deletion">Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter.</string>
<string name="allow_your_contacts_to_send_voice_messages">Autorise vos contacts à envoyer des messages vocaux.</string>
<string name="allow_voice_messages_only_if">Autoriser les messages vocaux uniquement si votre contact les autorise.</string>
<string name="prohibit_sending_voice_messages">Interdire l\'envoi de messages vocaux.</string>
<string name="only_you_can_send_disappearing">Seulement vous pouvez envoyer des messages éphémères.</string>
<string name="only_you_can_send_voice">Vous seul pouvez envoyer des messages vocaux.</string>
<string name="allow_to_delete_messages">Autoriser la suppression irréversible de messages envoyés.</string>
<string name="disappearing_messages_are_prohibited">Les messages éphémères sont interdits dans ce groupe.</string>
<string name="group_members_can_send_voice">Les membres du groupe peuvent envoyer des messages vocaux.</string>
<string name="delete_after">Supprimer après</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_s">%ds</string>
<string name="ttl_min">%d min</string>
<string name="ttl_month">%d mois</string>
<string name="ttl_months">%d mois</string>
<string name="ttl_m">%dm</string>
<string name="ttl_mth">%dm</string>
<string name="ttl_hour">%d heure</string>
<string name="ttl_hours">%d heures</string>
<string name="ttl_h">%dh</string>
<string name="ttl_day">%d jour</string>
<string name="ttl_days">%d jours</string>
<string name="ttl_d">%dj</string>
<string name="ttl_week">%d semaine</string>
<string name="ttl_weeks">%d semaines</string>
<string name="ttl_w">%dsmn</string>
<string name="timed_messages">Messages éphémères</string>
<string name="voice_messages">Messages vocaux</string>
<string name="feature_enabled">activé</string>
<string name="feature_enabled_for_you">activé pour vous</string>
<string name="feature_enabled_for_contact">activé pour le contact</string>
<string name="feature_off">off</string>
<string name="feature_received_prohibited">reçu, non autorisé</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Autorise votre contact à envoyer des messages éphémères.</string>
<string name="conn_level_desc_direct">directe</string>
<string name="group_is_decentralized">Le groupe est entièrement décentralisé il n\'est visible que par ses membres.</string>
<string name="group_members_can_send_disappearing">Les membres du groupes peuvent envoyer des messages éphémères.</string>
<string name="network_options_revert">Revenir en arrière</string>
<string name="prohibit_sending_disappearing_messages">Interdit lenvoi de messages éphémères.</string>
<string name="incognito_info_protects">Le mode Incognito protège la confidentialité de votre profil principal — pour chaque nouveau contact un nouveau profil aléatoire est créé.</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">La mise à jour des ces paramètres reconnectera le client à tous les serveurs.</string>
<string name="incognito_info_share">Lorsque vous partagez un profil incognito avec quelqu\'un, ce profil sera utilisé pour les groupes auxquels il vous invite.</string>
<string name="chat_preferences_yes">oui</string>
<string name="allow_disappearing_messages_only_if">Autorise les messages éphémères seulement si votre contact les autorises.</string>
<string name="allow_irreversible_message_deletion_only_if">Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise.</string>
<string name="only_your_contact_can_delete">Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer pour suppression).</string>
<string name="only_your_contact_can_send_disappearing">Seulement votre contact peut envoyer des messages éphémères.</string>
<string name="both_you_and_your_contact_can_send_disappearing">Vous et votre contact peuvent envoyer des messages éphémères.</string>
<string name="voice_messages_are_prohibited">Les messages vocaux sont interdits dans ce groupe.</string>
<string name="group_display_name_field">Nom affiché du groupe :</string>
<string name="group_unsupported_incognito_main_profile_sent">Le mode Incognito n\'est pas supporté ici - votre profil principal sera envoyé aux membres du groupe</string>
<string name="conn_level_desc_indirect">indirecte (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<string name="info_row_group">Groupe</string>
<string name="info_row_connection">Connexion</string>
<string name="network_option_seconds_label">sec</string>
<string name="network_option_tcp_connection_timeout">Délai de connexion TCP</string>
<string name="group_profile_is_stored_on_members_devices">Le profil du groupe est stocké sur les appareils des membres, pas sur les serveurs.</string>
<string name="save_group_profile">Sauvegarder le profil du groupe</string>
<string name="error_saving_group_profile">Erreur lors de la sauvegarde du profil de groupe</string>
<string name="network_options_reset_to_defaults">Réinitialisation des valeurs par défaut</string>
<string name="network_option_protocol_timeout">Délai du protocole</string>
<string name="network_option_ping_interval">Intervalle de PING</string>
<string name="both_you_and_your_contacts_can_delete">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
<string name="message_deletion_prohibited">La suppression irréversible de message est interdite dans ce chat.</string>
<string name="both_you_and_your_contact_can_send_voice">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
<string name="only_your_contact_can_send_voice">Seul votre contact peut envoyer des messages vocaux.</string>
<string name="voice_prohibited_in_this_chat">Les messages vocaux sont interdits dans ce chat.</string>
<string name="disappearing_prohibited_in_this_chat">Les messages éphémères sont interdits dans cette discussion.</string>
<string name="allow_to_send_voice">Autoriser l\'envoi de messages vocaux.</string>
<string name="prohibit_sending_voice">Interdire l\'envoi de messages vocaux.</string>
<string name="allow_to_send_disappearing">Autorise lenvoi de messages éphémères.</string>
<string name="prohibit_sending_disappearing">Interdit lenvoi de messages éphémères.</string>
<string name="prohibit_message_deletion">Interdire la suppression irréversible des messages.</string>
<string name="group_members_can_send_dms">Les membres du groupe peuvent envoyer des messages directs.</string>
<string name="direct_messages_are_prohibited_in_chat">Les messages directs entre membres sont interdits dans ce groupe.</string>
<string name="v4_4_live_messages_desc">Les destinataires voient les mises à jour au fur et à mesure que vous les tapez.</string>
<string name="v4_4_verify_connection_security">Vérifier la sécurité de la connexion</string>
<string name="v4_4_verify_connection_security_desc">Comparez les codes de sécurité avec vos contacts.</string>
<string name="new_in_version">Nouveautés de la %s</string>
<string name="v4_2_security_assessment">Évaluation de sécurité</string>
<string name="v4_2_group_links">Liens de groupe</string>
<string name="v4_2_auto_accept_contact_requests_desc">Avec message de bienvenue facultatif.</string>
<string name="v4_3_voice_messages">Messages vocaux</string>
<string name="v4_3_voice_messages_desc">Max 40 secondes, réception immédiate.</string>
<string name="v4_3_irreversible_message_deletion">Suppression irréversible des messages</string>
<string name="v4_3_irreversible_message_deletion_desc">Vos contacts peuvent autoriser la suppression complète des messages.</string>
<string name="v4_3_improved_privacy_and_security">Une meilleure sécurité et protection de la vie privée</string>
<string name="v4_3_improved_privacy_and_security_desc">Masquer l\'écran de l\'app dans les apps récentes.</string>
<string name="v4_4_disappearing_messages">Messages éphémères</string>
<string name="v4_4_disappearing_messages_desc">Les messages envoyés seront supprimés après une durée déterminée.</string>
<string name="v4_4_live_messages">Messages dynamiques</string>
<string name="accept_feature">Accepter</string>
<string name="v4_2_auto_accept_contact_requests">Demandes de contact auto-acceptées</string>
<string name="whats_new">Quoi de neuf \?</string>
<string name="v4_2_group_links_desc">Les admins peuvent créer les liens qui permettent de rejoindre les groupes.</string>
<string name="accept_feature_set_1_day">Définir 1 jour</string>
<string name="v4_2_security_assessment_desc">La sécurité de SimpleX Chat a été auditée par Trail of Bits.</string>
<string name="v4_3_improved_server_configuration">Configuration de serveur améliorée</string>
<string name="v4_3_improved_server_configuration_desc">Ajoutez des serveurs en scannant des codes QR.</string>
<string name="invalid_data">données invalides</string>
<string name="invalid_chat">chat invalide</string>
<string name="icon_descr_cancel_live_message">Annuler le message dynamique</string>
<string name="feature_offered_item">offert %s</string>
<string name="feature_offered_item_with_param">offert %s: %2s</string>
<string name="feature_cancelled_item">annulé %s</string>
</resources>
@@ -0,0 +1,925 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="simplex_link_mode">Link di SimpleX</string>
<string name="network_error_desc">Controlla la tua connessione di rete con <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> e riprova.</string>
<string name="service_notifications_disabled">Le notifiche istantanee sono disattivate!</string>
<string name="contact_connection_pending">in connessione…</string>
<string name="attach">Allega</string>
<string name="icon_descr_cancel_image_preview">Annulla anteprima immagine</string>
<string name="images_limit_desc">Possono essere inviate solo 10 immagini alla volta</string>
<string name="image_will_be_received_when_contact_is_online">L\'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi!</string>
<string name="waiting_for_image">In attesa dell\'immagine</string>
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="thousand_abbreviation">k</string>
<string name="connect_via_invitation_link">Connettere via link di invito\?</string>
<string name="connect_via_group_link">Connettere via link del gruppo\?</string>
<string name="profile_will_be_sent_to_contact_sending_link">Il tuo profilo verrà inviato al contatto da cui hai ricevuto questo link.</string>
<string name="connect_via_link_verb">Connetti</string>
<string name="server_connected">connesso</string>
<string name="server_error">errore</string>
<string name="server_connecting">in connessione</string>
<string name="connected_to_server_to_receive_messages_from_contact">Sei connesso al server usato per ricevere messaggi da questo contatto.</string>
<string name="trying_to_connect_to_server_to_receive_messages">Tentativo di connessione al server usato per ricevere messaggi da questo contatto.</string>
<string name="deleted_description">eliminato</string>
<string name="marked_deleted_description">contrassegnato eliminato</string>
<string name="sending_files_not_yet_supported">l\'invio di file non è ancora supportato</string>
<string name="receiving_files_not_yet_supported">la ricezione di file non è ancora supportata</string>
<string name="sender_you_pronoun">tu</string>
<string name="unknown_message_format">formato messaggio sconosciuto</string>
<string name="invalid_message_format">formato messaggio non valido</string>
<string name="live">IN DIRETTA</string>
<string name="invalid_chat">conversazione non valida</string>
<string name="invalid_data">dati non validi</string>
<string name="display_name_connection_established">connessione stabilita</string>
<string name="display_name_invited_to_connect">invitato a connettersi</string>
<string name="display_name_connecting">in connessione…</string>
<string name="description_you_shared_one_time_link">hai condiviso un link una tantum</string>
<string name="description_you_shared_one_time_link_incognito">hai condiviso un link incognito una tantum</string>
<string name="description_via_group_link">via link di gruppo</string>
<string name="description_via_group_link_incognito">incognito via link di gruppo</string>
<string name="description_via_contact_address_link">via link indirizzo del contatto</string>
<string name="description_via_contact_address_link_incognito">incognito via link indirizzo del contatto</string>
<string name="description_via_one_time_link">via link una tantum</string>
<string name="description_via_one_time_link_incognito">incognito via link una tantum</string>
<string name="simplex_link_contact">Indirizzo del contatto SimpleX</string>
<string name="simplex_link_invitation">Invito SimpleX una tantum</string>
<string name="simplex_link_group">Link gruppo SimpleX</string>
<string name="simplex_link_mode_full">Link completo</string>
<string name="simplex_link_mode_browser">Via browser</string>
<string name="error_saving_smp_servers">Errore di salvataggio server SMP</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Assicurati che gli indirizzi dei server SMP siano nel formato giusto, uno per riga e non doppi.</string>
<string name="error_setting_network_config">Errore di aggiornamento della configurazione di rete</string>
<string name="failed_to_parse_chat_title">Caricamento conversazione fallito</string>
<string name="failed_to_parse_chats_title">Caricamento delle chat fallito</string>
<string name="contact_developers">Aggiorna l\'app e contatta gli sviluppatori.</string>
<string name="connection_timeout">Connessione scaduta</string>
<string name="connection_error">Errore di connessione</string>
<string name="error_sending_message">Errore di invio del messaggio</string>
<string name="error_adding_members">Errore di aggiunta del/i membro/i</string>
<string name="error_joining_group">Errore di entrata nel gruppo</string>
<string name="cannot_receive_file">Impossibile ricevere il file</string>
<string name="sender_cancelled_file_transfer">Il mittente ha annullato il trasferimento del file.</string>
<string name="error_receiving_file">Errore di ricezione del file</string>
<string name="error_creating_address">Errore di creazione dell\'indirizzo</string>
<string name="contact_already_exists">Il contatto esiste già</string>
<string name="invalid_connection_link">Link di connessione non valido</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Controlla di aver usato il link giusto o chiedi al tuo contatto di inviartene un altro.</string>
<string name="connection_error_auth">Errore di connessione (AUTH)</string>
<string name="error_accepting_contact_request">Errore di accettazione della richiesta del contatto</string>
<string name="sender_may_have_deleted_the_connection_request">Il mittente potrebbe aver eliminato la richiesta di connessione.</string>
<string name="error_deleting_contact">Errore di eliminazione del contatto</string>
<string name="error_deleting_group">Errore di eliminazione del gruppo</string>
<string name="error_deleting_contact_request">Errore di eliminazione della richiesta di contatto</string>
<string name="error_deleting_pending_contact_connection">Errore di eliminazione della connessione del contatto in attesa</string>
<string name="error_changing_address">Errore di modifica dell\'indirizzo</string>
<string name="error_smp_test_failed_at_step">Test fallito al passo %s.</string>
<string name="error_smp_test_server_auth">Il server richiede l\'autorizzazione di creare code, controlla la password</string>
<string name="smp_server_test_connect">Connetti</string>
<string name="smp_server_test_create_queue">Crea coda</string>
<string name="smp_server_test_secure_queue">Coda sicura</string>
<string name="smp_server_test_delete_queue">Elimina coda</string>
<string name="smp_server_test_disconnect">Disconnetti</string>
<string name="icon_descr_instant_notifications">Notifiche istantanee</string>
<string name="service_notifications">Notifiche istantanee!</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Può essere disattivato nelle impostazioni</b>; le notifiche verranno comunque mostrate mentre l\'app è in uso.</string>
<string name="turning_off_service_and_periodic">L\'ottimizzazione della batteria è attiva, spegnimento del servizio in secondo piano e delle richieste periodiche di messaggi nuovi. Puoi riattivarli nelle impostazioni.</string>
<string name="periodic_notifications">Notifiche periodiche</string>
<string name="periodic_notifications_disabled">Le notifiche periodiche sono disattivate!</string>
<string name="periodic_notifications_desc">L\'app cerca nuovi messaggi periodicamente, utilizza una piccola percentuale di batteria al giorno. L\'app non usa notifiche push, non vengono inviati dati dal tuo dispositivo ai server.</string>
<string name="enter_passphrase_notification_title">Password necessaria</string>
<string name="enter_passphrase_notification_desc">Per ricevere notifiche, inserisci la password del database</string>
<string name="database_initialization_error_title">Impossibile inizializzare il database</string>
<string name="database_initialization_error_desc">Il database non funziona bene. Tocca per maggiori informazioni</string>
<string name="simplex_service_notification_text">Ricezione messaggi…</string>
<string name="hide_notification">Nascondi</string>
<string name="ntf_channel_messages">Messaggi di SimpleX Chat</string>
<string name="ntf_channel_calls">Chiamate di SimpleX Chat</string>
<string name="settings_notifications_mode_title">Servizio di notifica</string>
<string name="settings_notification_preview_mode_title">Mostra anteprima</string>
<string name="settings_notification_preview_title">Anteprima notifica</string>
<string name="notifications_mode_off">Quando l\'app è aperta</string>
<string name="notifications_mode_periodic">Periodicamente</string>
<string name="notifications_mode_service">Sempre attivo</string>
<string name="notifications_mode_off_desc">L\'app può ricevere notifiche solo quando è attiva, non verrà avviato alcun servizio in secondo piano</string>
<string name="notifications_mode_periodic_desc">Controlla messaggi nuovi ogni 10 minuti per massimo 1 minuto</string>
<string name="notification_preview_mode_message">Testo del messaggio</string>
<string name="notification_preview_mode_contact">Nome del contatto</string>
<string name="notification_preview_mode_hidden">Nascosta</string>
<string name="notification_preview_mode_message_desc">Mostra contatto e messaggio</string>
<string name="notification_preview_mode_contact_desc">Mostra solo il contatto</string>
<string name="notification_display_mode_hidden_desc">Nascondi contatto e messaggio</string>
<string name="notification_preview_somebody">Contatto nascosto:</string>
<string name="notification_preview_new_message">messaggio nuovo</string>
<string name="notification_new_contact_request">Nuova richiesta di contatto</string>
<string name="notification_contact_connected">Connesso</string>
<string name="la_notice_turn_on">Attiva</string>
<string name="auth_unlock">Sblocca</string>
<string name="auth_log_in_using_credential">Accedi usando le tue credenziali</string>
<string name="auth_enable_simplex_lock">Attiva SimpleX Lock</string>
<string name="auth_disable_simplex_lock">Disattiva SimpleX Lock</string>
<string name="auth_confirm_credential">Conferma le tue credenziali</string>
<string name="auth_unavailable">Autenticazione non disponibile</string>
<string name="auth_device_authentication_is_disabled_turning_off">L\'autenticazione del dispositivo è disattivata. Disattivazione di SimpleX Lock.</string>
<string name="auth_stop_chat">Ferma la chat</string>
<string name="auth_open_chat_console">Apri la console della chat</string>
<string name="message_delivery_error_title">Errore di recapito del messaggio</string>
<string name="message_delivery_error_desc">Probabilmente questo contatto ha eliminato la connessione con te.</string>
<string name="reply_verb">Rispondi</string>
<string name="share_verb">Condividi</string>
<string name="copy_verb">Copia</string>
<string name="save_verb">Salva</string>
<string name="edit_verb">Modifica</string>
<string name="delete_verb">Elimina</string>
<string name="reveal_verb">Rivela</string>
<string name="hide_verb">Nascondi</string>
<string name="allow_verb">Consenti</string>
<string name="delete_message__question">Eliminare il messaggio\?</string>
<string name="delete_message_cannot_be_undone_warning">Il messaggio verrà eliminato, non è reversibile!</string>
<string name="for_me_only">Elimina per me</string>
<string name="for_everybody">Per tutti</string>
<string name="icon_descr_edited">modificato</string>
<string name="icon_descr_sent_msg_status_sent">inviato</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">invio non autorizzato</string>
<string name="icon_descr_sent_msg_status_send_failed">invio fallito</string>
<string name="icon_descr_received_msg_status_unread">non letto</string>
<string name="personal_welcome">Benvenuto/a <xliff:g>%1$s</xliff:g>!</string>
<string name="welcome">Benvenuto/a!</string>
<string name="this_text_is_available_in_settings">Questo testo è disponibile nelle impostazioni</string>
<string name="your_chats">Le tue chat</string>
<string name="group_preview_you_are_invited">sei stato invitato in un gruppo</string>
<string name="group_preview_join_as">entra come %s</string>
<string name="group_connection_pending">in connessione…</string>
<string name="tap_to_start_new_chat">Tocca per iniziare una conversazione</string>
<string name="chat_with_developers">Scrivi agli sviluppatori</string>
<string name="you_have_no_chats">Non hai chat</string>
<string name="share_image">Condividi immagine…</string>
<string name="share_file">Condividi file…</string>
<string name="icon_descr_context">Icona contestuale</string>
<string name="icon_descr_cancel_file_preview">Annulla anteprima file</string>
<string name="images_limit_title">Troppe immagini!</string>
<string name="image_decoding_exception_title">Errore di decodifica</string>
<string name="image_decoding_exception_desc">L\'immagine non può essere decodificata. Prova con un\'altra o contatta gli sviluppatori.</string>
<string name="image_descr">Immagine</string>
<string name="icon_descr_waiting_for_image">In attesa dell\'immagine</string>
<string name="icon_descr_asked_to_receive">Richiesta di ricezione immagine</string>
<string name="icon_descr_image_snd_complete">Immagine inviata</string>
<string name="image_saved">Immagine salvata nella Galleria</string>
<string name="icon_descr_file">File</string>
<string name="large_file">File grande!</string>
<string name="maximum_supported_file_size">Attualmente la dimensione massima supportata è di <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
<string name="waiting_for_file">In attesa del file</string>
<string name="file_will_be_received_when_contact_is_online">Il file verrà ricevuto quando il tuo contatto sarà in linea, aspetta o controlla più tardi!</string>
<string name="file_saved">File salvato</string>
<string name="file_not_found">File non trovato</string>
<string name="error_saving_file">Errore di salvataggio del file</string>
<string name="voice_message">Messaggio vocale</string>
<string name="voice_message_with_duration">Messaggio vocale (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="voice_message_send_text">Messaggio vocale…</string>
<string name="notifications">Notifiche</string>
<string name="delete_contact_question">Eliminare il contatto\?</string>
<string name="button_delete_contact">Elimina contatto</string>
<string name="text_field_set_contact_placeholder">Imposta nome del contatto…</string>
<string name="icon_descr_server_status_connected">Connesso</string>
<string name="icon_descr_server_status_disconnected">Disconnesso</string>
<string name="icon_descr_server_status_error">Errore</string>
<string name="icon_descr_server_status_pending">In attesa</string>
<string name="switch_receiving_address_question">Cambiare l\'indirizzo di ricezione\?</string>
<string name="view_security_code">Vedi codice di sicurezza</string>
<string name="verify_security_code">Verifica codice di sicurezza</string>
<string name="icon_descr_send_message">Invia messaggio</string>
<string name="icon_descr_record_voice_message">Registra messaggio vocale</string>
<string name="allow_voice_messages_question">Permettere i messaggi vocali\?</string>
<string name="you_need_to_allow_to_send_voice">Devi consentire al tuo contatto di inviare messaggi vocali per poterli inviare anche tu.</string>
<string name="voice_messages_prohibited">Messaggi vocali vietati!</string>
<string name="ask_your_contact_to_enable_voice">Chiedi al tuo contatto di attivare l\'invio dei messaggi vocali.</string>
<string name="send_live_message">Invia messaggio in diretta</string>
<string name="live_message">Messaggio in diretta!</string>
<string name="send_verb">Invia</string>
<string name="back">Indietro</string>
<string name="cancel_verb">Annulla</string>
<string name="confirm_verb">Conferma</string>
<string name="reset_verb">Ripristina</string>
<string name="ok">OK</string>
<string name="connect_via_contact_link">Connettere via link del contatto\?</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="you_will_join_group">Entrerai in un gruppo a cui si riferisce questo link e ti connetterai ai suoi membri.</string>
<string name="connection_local_display_name">connessione <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="simplex_link_mode_description">Descrizione</string>
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode_browser_warning">Aprire il link nel browser può ridurre la privacy e la sicurezza della connessione. I link SimpleX non fidati saranno in rosso.</string>
<string name="you_are_already_connected_to_vName_via_this_link">Sei già connesso a <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
<string name="connection_error_auth_desc">A meno che il tuo contatto non abbia eliminato la connessione o che questo link non sia già stato usato, potrebbe essere un errore; per favore segnalalo.
\nPer connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile.</string>
<string name="error_smp_test_certificate">Probabilmente l\'impronta del certificato nell\'indirizzo del server è sbagliata</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Per rispettare la tua privacy, invece delle notifiche push l\'app ha un <b>servizio <xliff:g id="appName">SimpleX</xliff:g> in secondo piano</b>; usa una piccola percentuale di batteria al giorno.</string>
<string name="turn_off_battery_optimization">Per poterlo usare, <b>disattiva l\'ottimizzazione della batteria</b> per <xliff:g id="appName">SimpleX</xliff:g> nella prossima schermata. Altrimenti le notifiche saranno disattivate.</string>
<string name="simplex_service_notification_title">Servizio <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="notifications_mode_service_desc">Servizio in secondo piano sempre attivo. Le notifiche verranno mostrate appena i messaggi saranno disponibili.</string>
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Per proteggere le tue informazioni, attiva SimpleX Lock.
\nTi verrà chiesto di completare l\'autenticazione prima di attivare questa funzionalità.</string>
<string name="auth_simplex_lock_turned_on">SimpleX Lock attivo</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Dovrai autenticarti quando avvii o riapri l\'app dopo 30 secondi in secondo piano.</string>
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">L\'autenticazione del dispositivo non è attiva. Potrai attivare SimpleX Lock nelle impostazioni, quando avrai attivato l\'autenticazione del dispositivo.</string>
<string name="delete_message_mark_deleted_warning">Il messaggio verrà contrassegnato per l\'eliminazione. I destinatari potranno rivelare questo messaggio.</string>
<string name="share_message">Condividi messaggio…</string>
<string name="contact_sent_large_file">Il tuo contatto ha inviato un file più grande della dimensione massima supportata (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Il contatto e tutti i messaggi verranno eliminati, non è reversibile!</string>
<string name="switch_receiving_address_desc">Questa funzionalità è sperimentale! Funzionerà solo se l\'altro client ha la versione 4.2 installata. Dovresti vedere il messaggio nella conversazione una volta completato il cambio di indirizzo. Controlla di potere ancora ricevere messaggi da questo contatto (o membro del gruppo).</string>
<string name="only_group_owners_can_enable_voice">Solo i proprietari del gruppo possono attivare i messaggi vocali.</string>
<string name="send_live_message_desc">Invia un messaggio in diretta: si aggiornerà per i destinatari mentre lo digiti</string>
<string name="chat_item_ttl_day">1 giorno</string>
<string name="a_plus_b">a + b</string>
<string name="about_simplex_chat">Riguardo <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="group_member_role_admin">amministratore</string>
<string name="chat_item_ttl_week">1 settimana</string>
<string name="smp_servers_add_to_another_device">Aggiungi ad un altro dispositivo</string>
<string name="accept">Accetta</string>
<string name="v4_2_group_links_desc">Gli amministratori possono creare i link per entrare nei gruppi.</string>
<string name="allow_disappearing_messages_only_if">Consenti i messaggi a tempo solo se il tuo contatto li consente.</string>
<string name="allow_to_delete_messages">Permetti di eliminare irreversibilmente i messaggi inviati.</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Permetti ai tuoi contatti di inviare messaggi a tempo.</string>
<string name="accept_requests">Accetta le richieste</string>
<string name="network_enable_socks_info">Accedere ai server via proxy SOCKS sulla porta 9050\? Il proxy deve essere avviato prima di attivare questa opzione.</string>
<string name="v4_3_improved_server_configuration_desc">Aggiungi server scansionando codici QR.</string>
<string name="all_group_members_will_remain_connected">Tutti i membri del gruppo resteranno connessi.</string>
<string name="allow_irreversible_message_deletion_only_if">Consenti l\'eliminazione irreversibile dei messaggi solo se il contatto la consente a te.</string>
<string name="above_then_preposition_continuation">sopra, quindi:</string>
<string name="accept_contact_button">Accetta</string>
<string name="accept_connection_request__question">Accettare la richiesta di connessione\?</string>
<string name="accept_contact_incognito_button">Accetta in incognito</string>
<string name="clear_chat_warning">Tutti i messaggi verranno eliminati, non è reversibile! I messaggi verranno eliminati SOLO per te.</string>
<string name="smp_servers_preset_add">Aggiungi server preimpostati</string>
<string name="smp_servers_add">Aggiungi server…</string>
<string name="network_settings">Impostazioni di rete avanzate</string>
<string name="about_simplex">Riguardo SimpleX</string>
<string name="callstatus_accepted">chiamata accettata</string>
<string name="accept_call_on_lock_screen">Accetta</string>
<string name="color_primary">Principale</string>
<string name="accept_feature">Accetta</string>
<string name="allow_voice_messages_only_if">Consenti i messaggi vocali solo se il tuo contatto li consente.</string>
<string name="allow_your_contacts_irreversibly_delete">Permetti ai tuoi contatti di eliminare irreversibilmente i messaggi inviati.</string>
<string name="allow_direct_messages">Permetti l\'invio di messaggi diretti ai membri.</string>
<string name="allow_to_send_disappearing">Permetti l\'invio di messaggi a tempo.</string>
<string name="allow_to_send_voice">Permetti l\'invio di messaggi vocali.</string>
<string name="chat_item_ttl_month">1 mese</string>
<string name="error_importing_database">Errore nell\'importazione del database della chat</string>
<string name="group_full_name_field">Nome completo del gruppo:</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Se non potete incontrarvi di persona, puoi <b>scansionare il codice QR nella videochiamata</b>, oppure il tuo contatto può condividere un link di invito.</string>
<string name="full_backup">Backup dei dati dell\'app</string>
<string name="keychain_is_storing_securely">Android Keystore è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica.</string>
<string name="allow_your_contacts_to_send_voice_messages">Permetti ai tuoi contatti di inviare messaggi vocali.</string>
<string name="chat_database_deleted">Database della chat eliminato</string>
<string name="settings_section_title_icon">ICONA APP</string>
<string name="incognito_random_profile_from_contact_description">Verrà inviato un profilo casuale al contatto da cui hai ricevuto questo link</string>
<string name="incognito_random_profile_description">Verrà inviato un profilo casuale al tuo contatto</string>
<string name="onboarding_notifications_mode_off_desc"><b>Ideale per la batteria</b>. Riceverai notifiche solo quando l\'app è in esecuzione, il servizio in secondo piano NON verrà usato.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Consuma più batteria</b>! Il servizio in secondo piano è sempre attivo: le notifiche verranno mostrate non appena i messaggi saranno disponibili.</string>
<string name="callstatus_calling">chiamata…</string>
<string name="icon_descr_cancel_link_preview">annulla anteprima link</string>
<string name="cannot_access_keychain">Impossibile accedere al Keystore per salvare la password del database</string>
<string name="alert_title_cant_invite_contacts">Impossibile invitare i contatti!</string>
<string name="change_role">Cambia ruolo</string>
<string name="chat_archive_section">ARCHIVIO CHAT</string>
<string name="snd_conn_event_switch_queue_phase_changing">cambio indirizzo…</string>
<string name="chat_is_stopped">Chat fermata</string>
<string name="group_member_status_introduced">connessione (presentato)</string>
<string name="contact_requests">Richieste del contatto</string>
<string name="connection_request_sent">Richiesta di connessione inviata!</string>
<string name="delete_link_question">Eliminare il link\?</string>
<string name="delete_link">Elimina link</string>
<string name="create_address">Crea indirizzo</string>
<string name="button_create_group_link">Crea link</string>
<string name="data_section">DATI</string>
<string name="database_encryption_will_be_updated">La password di crittografia del database verrà aggiornata e conservata nel Keystore.</string>
<string name="encrypted_with_random_passphrase">Il database è crittografato con una password casuale, puoi cambiarla.</string>
<string name="database_passphrase_is_required">La password del database è necessaria per aprire la chat.</string>
<string name="delete_group_menu_action">Elimina</string>
<string name="direct_messages_are_prohibited_in_chat">I messaggi diretti tra i membri sono vietati in questo gruppo.</string>
<string name="display_name">Nome da mostrare</string>
<string name="add_new_contact_to_create_one_time_QR_code"><b>Aggiungi un contatto</b>: per creare il tuo codice QR una tantum per il tuo contatto.</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scansiona codice QR</b>: per connetterti al contatto che ti mostra il codice QR.</string>
<string name="choose_file">Scegli file</string>
<string name="clear_chat_button">Svuota chat</string>
<string name="clear_chat_question">Svuotare la chat\?</string>
<string name="clear_verb">Svuota</string>
<string name="connect_via_link_or_qr">Connetti via link / codice QR</string>
<string name="copied">Copiato negli appunti</string>
<string name="share_one_time_link">Crea link di invito una tantum</string>
<string name="create_group">Crea gruppo segreto</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 desktop: scansiona dall\'app il codice QR mostrato, tramite <b>Scansiona codice QR</b>.</string>
<string name="from_gallery_button">Dalla Galleria</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Se scegli di rifiutare, il mittente NON verrà avvisato.</string>
<string name="clear_chat_menu_action">Svuota</string>
<string name="icon_descr_close_button">Pulsante di chiusura</string>
<string name="alert_title_contact_connection_pending">Il contatto non è ancora connesso!</string>
<string name="delete_contact_menu_action">Elimina</string>
<string name="delete_pending_connection__question">Eliminare la connessione in attesa\?</string>
<string name="icon_descr_email">Email</string>
<string name="icon_descr_help">aiuto</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Se non potete incontrarvi di persona, <b>mostra il codice QR nella videochiamata</b>, oppure condividi il link.</string>
<string name="chat_console">Console della chat</string>
<string name="clear_verification">Annulla la verifica</string>
<string name="connect_button">Connetti</string>
<string name="connect_via_link">Connetti via link</string>
<string name="create_one_time_link">Crea link di invito una tantum</string>
<string name="database_passphrase_and_export">Password del database ed esportazione</string>
<string name="smp_servers_enter_manually">Inserisci il server manualmente</string>
<string name="how_to_use_simplex_chat">Come si usa</string>
<string name="all_your_contacts_will_remain_connected">Tutti i tuoi contatti resteranno connessi.</string>
<string name="appearance_settings">Aspetto</string>
<string name="smp_servers_check_address">Controlla l\'indirizzo del server e riprova.</string>
<string name="configure_ICE_servers">Configura server ICE</string>
<string name="contribute">Contribuisci</string>
<string name="delete_address">Elimina indirizzo</string>
<string name="delete_address__question">Eliminare l\'indirizzo\?</string>
<string name="smp_servers_delete_server">Elimina server</string>
<string name="error_saving_ICE_servers">Errore nel salvataggio dei server ICE</string>
<string name="how_to">Come si fa</string>
<string name="how_to_use_your_servers">Come usare i tuoi server</string>
<string name="enter_one_ICE_server_per_line">Server ICE (uno per riga)</string>
<string name="accept_automatically">Automaticamente</string>
<string name="bold">grassetto</string>
<string name="callstatus_ended">chiamata terminata <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">errore di chiamata</string>
<string name="callstatus_in_progress">chiamata in corso</string>
<string name="colored">colorato</string>
<string name="callstate_connected">connesso</string>
<string name="callstate_connecting">connessione…</string>
<string name="callstatus_connecting">connessione chiamata…</string>
<string name="create_profile_button">Crea</string>
<string name="create_profile">Crea profilo</string>
<string name="delete_image">Elimina immagine</string>
<string name="display_name__field">Nome da mostrare:</string>
<string name="display_name_cannot_contain_whitespace">Il nome da mostrare non può contenere spazi.</string>
<string name="edit_image">Modifica immagine</string>
<string name="exit_without_saving">Esci senza salvare</string>
<string name="full_name__field">Nome completo:</string>
<string name="full_name_optional__prompt">Nome completo (facoltativo)</string>
<string name="how_to_use_markdown">Come usare il markdown</string>
<string name="icon_descr_audio_call">chiamata audio</string>
<string name="audio_call_no_encryption">chiamata audio (non crittografata e2e)</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Buono per la batteria</b>. Il servizio in secondo piano controlla nuovi messaggi ogni 10 minuti. Potresti perdere chiamate e messaggi urgenti.</string>
<string name="call_already_ended">Chiamata già terminata!</string>
<string name="create_your_profile">Crea il tuo profilo</string>
<string name="decentralized">Decentralizzato</string>
<string name="encrypted_audio_call">Chiamata crittografata e2e</string>
<string name="encrypted_video_call">Videochiamata crittografata e2e</string>
<string name="callstate_ended">terminata</string>
<string name="how_it_works">Come funziona</string>
<string name="how_simplex_works">Come funziona <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="answer_call">Rispondi alla chiamata</string>
<string name="icon_descr_audio_off">Audio spento</string>
<string name="icon_descr_audio_on">Audio acceso</string>
<string name="settings_audio_video_calls">Chiamate audio e video</string>
<string name="auto_accept_images">Auto-accetta immagini</string>
<string name="integrity_msg_bad_hash">hash del messaggio errato</string>
<string name="integrity_msg_bad_id">ID messaggio errato</string>
<string name="icon_descr_call_ended">Chiamata terminata</string>
<string name="icon_descr_call_progress">Chiamata in corso</string>
<string name="call_on_lock_screen">Chiamate sulla schermata di blocco:</string>
<string name="icon_descr_call_connecting">Connessione chiamata</string>
<string name="connect_calls_via_relay">Connetti via relay</string>
<string name="status_contact_has_e2e_encryption">il contatto ha la crittografia e2e</string>
<string name="status_contact_has_no_e2e_encryption">il contatto non ha la crittografia e2e</string>
<string name="no_call_on_lock_screen">Disattiva</string>
<string name="integrity_msg_duplicate">messaggio duplicato</string>
<string name="status_e2e_encrypted">crittografato e2e</string>
<string name="allow_accepting_calls_from_lock_screen">Attiva le chiamate dalla schermata di blocco tramite le impostazioni.</string>
<string name="icon_descr_flip_camera">Fotocamera frontale/posteriore</string>
<string name="icon_descr_hang_up">Riaggancia</string>
<string name="settings_section_title_calls">CHIAMATE</string>
<string name="chat_database_section">DATABASE DELLA CHAT</string>
<string name="chat_database_imported">Database della chat importato</string>
<string name="chat_is_running">Chat in esecuzione</string>
<string name="settings_section_title_chats">CHAT</string>
<string name="set_password_to_export_desc">Il database è crittografato con una password casuale. Cambiala prima di esportare.</string>
<string name="database_passphrase">Password del database</string>
<string name="delete_chat_profile_question">Eliminare il profilo di chat\?</string>
<string name="delete_database">Elimina database</string>
<string name="settings_section_title_develop">SVILUPPA</string>
<string name="settings_developer_tools">Strumenti di sviluppo</string>
<string name="settings_section_title_device">DISPOSITIVO</string>
<string name="error_deleting_database">Errore nell\'eliminazione del database della chat</string>
<string name="error_exporting_chat_database">Errore nell\'esportazione del database della chat</string>
<string name="error_starting_chat">Errore nell\'avvio della chat</string>
<string name="error_stopping_chat">Errore nell\'interruzione della chat</string>
<string name="settings_experimental_features">Funzionalità sperimentali</string>
<string name="export_database">Esporta database</string>
<string name="settings_section_title_help">AIUTO</string>
<string name="chat_archive_header">Archivio chat</string>
<string name="chat_is_stopped_indication">Chat fermata</string>
<string name="archive_created_on_ts">Creato il <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="database_error">Errore del database</string>
<string name="passphrase_is_different">La password del database è diversa da quella salvata nel Keystore.</string>
<string name="delete_archive">Elimina archivio</string>
<string name="delete_chat_archive_question">Eliminare l\'archivio della chat\?</string>
<string name="encrypted_database">Database crittografato</string>
<string name="enter_correct_passphrase">Inserisci la password giusta.</string>
<string name="enter_passphrase">Inserisci la password…</string>
<string name="error_with_info">Errore: %s</string>
<string name="file_with_path">File: %s</string>
<string name="icon_descr_group_inactive">Gruppo inattivo</string>
<string name="rcv_conn_event_switch_queue_phase_completed">indirizzo cambiato per te</string>
<string name="rcv_group_event_changed_member_role">cambiato il ruolo di %s in %s</string>
<string name="rcv_group_event_changed_your_role">cambiato il tuo ruolo in %s</string>
<string name="rcv_conn_event_switch_queue_phase_changing">cambio indirizzo…</string>
<string name="snd_conn_event_switch_queue_phase_changing_for_member">cambio indirizzo per %s…</string>
<string name="rcv_group_event_member_connected">connesso</string>
<string name="group_member_status_connected">connesso</string>
<string name="group_member_status_accepted">connessione (accettato)</string>
<string name="group_member_status_announced">connessione (annunciato)</string>
<string name="group_member_status_intro_invitation">connessione (invito di presentazione)</string>
<string name="rcv_group_event_group_deleted">gruppo eliminato</string>
<string name="group_member_status_group_deleted">gruppo eliminato</string>
<string name="group_invitation_expired">Invito al gruppo scaduto</string>
<string name="alert_message_group_invitation_expired">L\'invito al gruppo non è più valido, è stato rimosso dal mittente.</string>
<string name="alert_title_no_group">Gruppo non trovato!</string>
<string name="snd_group_event_group_profile_updated">profilo del gruppo aggiornato</string>
<string name="invite_prohibited">Impossibile invitare il contatto!</string>
<string name="change_verb">Cambia</string>
<string name="change_member_role_question">Cambiare il ruolo del gruppo\?</string>
<string name="clear_contacts_selection_button">Svuota</string>
<string name="group_member_status_complete">completo</string>
<string name="group_member_status_connecting">connessione</string>
<string name="icon_descr_contact_checked">Contatto controllato</string>
<string name="create_group_link">Crea link del gruppo</string>
<string name="group_member_status_creator">creatore</string>
<string name="info_row_database_id">ID database</string>
<string name="button_delete_group">Elimina gruppo</string>
<string name="delete_group_question">Eliminare il gruppo\?</string>
<string name="button_edit_group_profile">Modifica il profilo del gruppo</string>
<string name="error_creating_link_for_group">Errore nella creazione del link del gruppo</string>
<string name="error_deleting_link_for_group">Errore nell\'eliminazione del link del gruppo</string>
<string name="icon_descr_expand_role">Espandi la selezione dei ruoli</string>
<string name="section_title_for_console">PER CONSOLE</string>
<string name="group_link">Link del gruppo</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Il gruppo verrà eliminato per tutti i membri. Non è reversibile!</string>
<string name="delete_group_for_self_cannot_undo_warning">Il gruppo verrà eliminato per te. Non è reversibile!</string>
<string name="info_row_connection">Connessione</string>
<string name="create_secret_group_title">Crea gruppo segreto</string>
<string name="conn_level_desc_direct">diretta</string>
<string name="network_option_enable_tcp_keep_alive">Attiva il keep-alive TCP</string>
<string name="error_changing_role">Errore nel cambio di ruolo</string>
<string name="error_removing_member">Errore nella rimozione del membro</string>
<string name="error_saving_group_profile">Errore nel salvataggio del profilo del gruppo</string>
<string name="info_row_group">Gruppo</string>
<string name="group_display_name_field">Nome da mostrare del gruppo:</string>
<string name="group_profile_is_stored_on_members_devices">Il profilo del gruppo è memorizzato sui dispositivi dei membri, non sui server.</string>
<string name="chat_preferences_always">sempre</string>
<string name="both_you_and_your_contacts_can_delete">Sia tu che il tuo contatto potete eliminare irreversibilmente i messaggi inviati.</string>
<string name="both_you_and_your_contact_can_send_disappearing">Sia tu che il tuo contatto potete inviare messaggi a tempo.</string>
<string name="both_you_and_your_contact_can_send_voice">Sia tu che il tuo contatto potete inviare messaggi vocali.</string>
<string name="chat_preferences">Preferenze della chat</string>
<string name="chat_preferences_contact_allows">Il contatto lo consente</string>
<string name="contact_preferences">Preferenze del contatto</string>
<string name="contacts_can_mark_messages_for_deletion">I contatti possono contrassegnare i messaggi per l\'eliminazione; potrai vederli.</string>
<string name="theme_dark">Scuro</string>
<string name="chat_preferences_default">predefinito (%s)</string>
<string name="full_deletion">Elimina per tutti</string>
<string name="direct_messages">Messaggi diretti</string>
<string name="timed_messages">Messaggi a tempo</string>
<string name="disappearing_prohibited_in_this_chat">I messaggi a tempo sono vietati in questa conversazione.</string>
<string name="feature_enabled">attivato</string>
<string name="feature_enabled_for_contact">attivato per il contatto</string>
<string name="feature_enabled_for_you">attivato per te</string>
<string name="group_preferences">Preferenze del gruppo</string>
<string name="v4_2_auto_accept_contact_requests">Auto-accetta richieste di contatto</string>
<string name="ttl_d">%dg</string>
<string name="ttl_day">%d giorno</string>
<string name="ttl_days">%d giorni</string>
<string name="delete_after">Elimina dopo</string>
<string name="ttl_h">%do</string>
<string name="ttl_hour">%d ora</string>
<string name="ttl_hours">%d ore</string>
<string name="disappearing_messages_are_prohibited">I messaggi a tempo sono vietati in questo gruppo.</string>
<string name="ttl_m">%dm</string>
<string name="ttl_min">%d min</string>
<string name="ttl_month">%d mese</string>
<string name="ttl_months">%d mesi</string>
<string name="ttl_mth">%dmese</string>
<string name="ttl_s">%ds</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_w">%dset</string>
<string name="ttl_week">%d settimana</string>
<string name="ttl_weeks">%d settimane</string>
<string name="v4_2_group_links">Link del gruppo</string>
<string name="group_members_can_delete">I membri del gruppo possono eliminare irreversibilmente i messaggi inviati.</string>
<string name="group_members_can_send_dms">I membri del gruppo possono inviare messaggi diretti.</string>
<string name="group_members_can_send_disappearing">I membri del gruppo possono inviare messaggi a tempo.</string>
<string name="group_members_can_send_voice">I membri del gruppo possono inviare messaggi vocali.</string>
<string name="v4_4_verify_connection_security_desc">Confronta i codici di sicurezza con i tuoi contatti.</string>
<string name="v4_4_disappearing_messages">Messaggi a tempo</string>
<string name="v4_3_improved_privacy_and_security_desc">Nascondi la schermata dell\'app nelle app recenti.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore verrà usato per memorizzare in modo sicuro la password dopo il riavvio dell\'app o la modifica della password; consentirà di ricevere le notifiche.</string>
<string name="impossible_to_recover_passphrase"><b>Nota bene</b>: NON potrai recuperare o cambiare la password se la perdi.</string>
<string name="change_database_passphrase_question">Cambiare password del database\?</string>
<string name="confirm_new_passphrase">Conferma password nuova…</string>
<string name="current_passphrase">Password attuale…</string>
<string name="database_encrypted">Database crittografato!</string>
<string name="database_passphrase_will_be_updated">La password di crittografia del database verrà aggiornata.</string>
<string name="database_will_be_encrypted">Il database verrà crittografato.</string>
<string name="database_will_be_encrypted_and_passphrase_stored">Il database verrà crittografato e la password conservata nel Keystore.</string>
<string name="delete_files_and_media_question">Eliminare i file e i multimediali\?</string>
<string name="delete_files_and_media">"Elimina file e multimediali"</string>
<string name="delete_messages">Elimina messaggi</string>
<string name="delete_messages_after">Elimina messaggi dopo</string>
<string name="total_files_count_and_size">%d file con dimensione totale di %s</string>
<string name="enable_automatic_deletion_question">Attivare l\'eliminazione automatica dei messaggi\?</string>
<string name="encrypt_database_question">Crittografare il database\?</string>
<string name="encrypt_database">Crittografare</string>
<string name="error_changing_message_deletion">Errore nella modifica dell\'impostazione</string>
<string name="error_encrypting_database">Errore nella crittografia del database</string>
<string name="your_settings">Le tue impostazioni</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Verrai connesso/a al gruppo quando il dispositivo dell\'host del gruppo sarà in linea, attendi o controlla più tardi!</string>
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Se hai ricevuto il link di invito a <xliff:g id="appName">SimpleX Chat</xliff:g>, puoi aprirlo nel tuo browser:</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile: tocca <b>Apri nell\'app mobile</b>, quindi <b>Connetti</b> nell\'app.</string>
<string name="no_details">nessun dettaglio</string>
<string name="add_contact">Link di invito una tantum</string>
<string name="only_stored_on_members_devices">(memorizzato solo dai membri del gruppo)</string>
<string name="toast_permission_denied">Autorizzazione negata!</string>
<string name="reject_contact_button">Rifiuta</string>
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(scansiona o incolla dagli appunti)</string>
<string name="scan_QR_code">Scansiona codice QR</string>
<string name="add_contact_or_create_group">Inizia una nuova conversazione</string>
<string name="chat_help_tap_button">Tocca il pulsante</string>
<string name="thank_you_for_installing_simplex">Grazie per aver installato <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="to_connect_via_link_title">Per connettersi via link</string>
<string name="to_share_with_your_contact">(da condividere con il tuo contatto)</string>
<string name="to_start_a_new_chat_help_header">Per iniziare una nuova chat</string>
<string name="use_camera_button">Usa la fotocamera</string>
<string name="you_can_connect_to_simplex_chat_founder">Puoi <font color="#0088ff">connetterti con gli sviluppatori di <xliff:g id="appNameFull">SimpleX Chat</xliff:g> per porre domande e ricevere aggiornamenti</font>.</string>
<string name="invalid_contact_link">Link non valido!</string>
<string name="invalid_QR_code">Codice QR non valido</string>
<string name="image_descr_link_preview">immagine di anteprima link</string>
<string name="mark_read">Segna come già letto</string>
<string name="mark_unread">Segna come non letto</string>
<string name="icon_descr_more_button">Altro</string>
<string name="mute_chat">Silenzia</string>
<string name="image_descr_profile_image">immagine del profilo</string>
<string name="icon_descr_profile_image_placeholder">segnaposto immagine del profilo</string>
<string name="image_descr_qr_code">Codice QR</string>
<string name="set_contact_name">Imposta il nome del contatto</string>
<string name="icon_descr_settings">Impostazioni</string>
<string name="show_QR_code">Mostra codice QR</string>
<string name="connection_you_accepted_will_be_cancelled">La connessione che hai accettato verrà annullata!</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Il contatto con cui hai condiviso questo link NON sarà in grado di connettersi!</string>
<string name="this_link_is_not_a_valid_connection_link">Questo non è un link di connessione valido!</string>
<string name="this_QR_code_is_not_a_link">Questo codice QR non è un link!</string>
<string name="unmute_chat">Riattiva audio</string>
<string name="contact_wants_to_connect_with_you">vuole connettersi con te!</string>
<string name="image_descr_simplex_logo">Logo di <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_address">Indirizzo di <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="icon_descr_simplex_team">Squadra di <xliff:g id="appName">SimpleX</xliff:g></string>
<string name="you_accepted_connection">Hai accettato la connessione</string>
<string name="you_invited_your_contact">Hai invitato il contatto</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Il tuo profilo di chat verrà inviato
\nal tuo contatto</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Il tuo contatto può scansionare il codice QR dall\'app.</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Il tuo contatto deve essere in linea per completare la connessione.
\nPuoi annullare questa connessione e rimuovere il contatto (e riprovare più tardi con un link nuovo).</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Verrai connesso/a quando la tua richiesta di connessione verrà accettata, attendi o controlla più tardi!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Verrai connesso/a quando il dispositivo del tuo contatto sarà in linea, attendi o controlla più tardi!</string>
<string name="incorrect_code">Codice di sicurezza sbagliato!</string>
<string name="smp_servers_invalid_address">Indirizzo del server non valido!</string>
<string name="markdown_help">Aiuto sul markdown</string>
<string name="markdown_in_messages">Markdown nei messaggi</string>
<string name="mark_code_verified">Segna come verificato/a</string>
<string name="one_time_link">Link di invito una tantum</string>
<string name="paste_button">Incolla</string>
<string name="paste_connection_link_below_to_connect">Incolla il link che hai ricevuto nella casella sottostante per connetterti con il tuo contatto.</string>
<string name="smp_servers_preset_server">Server preimpostato</string>
<string name="smp_servers_preset_address">Indirizzo server preimpostato</string>
<string name="smp_servers_save">Salva i server</string>
<string name="scan_code">Scansiona codice</string>
<string name="scan_code_from_contacts_app">Scansiona il codice di sicurezza dall\'app del tuo contatto.</string>
<string name="smp_servers_scan_qr">Scansiona codice QR del server</string>
<string name="security_code">Codice di sicurezza</string>
<string name="chat_with_the_founder">Invia domande e idee</string>
<string name="send_us_an_email">Inviaci un\'email</string>
<string name="smp_servers_test_failed">Test del server fallito!</string>
<string name="share_invitation_link">Condividi link di invito</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="is_not_verified">%s non è verificato/a</string>
<string name="is_verified">%s è verificato/a</string>
<string name="smp_servers">Server SMP</string>
<string name="smp_servers_test_some_failed">Alcuni server hanno fallito il test:</string>
<string name="smp_servers_test_server">Testa server</string>
<string name="smp_servers_test_servers">Testa i server</string>
<string name="this_string_is_not_a_connection_link">Questa stringa non è un link di connessione!</string>
<string name="to_verify_compare">Per verificare la crittografia end-to-end con il tuo contatto, confrontate (o scansionate) il codice sui vostri dispositivi.</string>
<string name="smp_servers_use_server_for_new_conn">Usa per connessioni nuove</string>
<string name="smp_servers_use_server">Usa il server</string>
<string name="you_can_also_connect_by_clicking_the_link">Puoi anche connetterti cliccando il link. Se si apre nel browser, clicca il pulsante <b>Apri nell\'app mobile</b>.</string>
<string name="your_profile_will_be_sent">Il tuo profilo di chat verrà inviato al tuo contatto</string>
<string name="your_contact_address">Il tuo indirizzo di contatto</string>
<string name="smp_servers_your_server">Il tuo server</string>
<string name="smp_servers_your_server_address">L\'indirizzo del tuo server</string>
<string name="your_simplex_contact_address">Il tuo indirizzo di contatto di <xliff:g id="appName">SimpleX</xliff:g>.</string>
<string name="network_disable_socks_info">Se confermi, i server di messaggistica saranno in grado di vedere il tuo indirizzo IP e il tuo fornitore, a quali server ti stai connettendo.</string>
<string name="install_simplex_chat_for_terminal">Installa <xliff:g id="appNameFull">SimpleX Chat</xliff:g> per terminale</string>
<string name="ensure_ICE_server_address_are_correct_format_and_unique">Assicurati che gli indirizzi dei server WebRTC ICE siano nel formato corretto, uno per riga e non doppi.</string>
<string name="network_and_servers">Rete e server</string>
<string name="network_settings_title">Impostazioni di rete</string>
<string name="network_use_onion_hosts_no">No</string>
<string name="network_use_onion_hosts_required_desc">Gli host Onion saranno necessari per la connessione.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Gli host Onion saranno necessari per la connessione.</string>
<string name="network_use_onion_hosts_prefer_desc">Gli host Onion verranno usati quando disponibili.</string>
<string name="network_use_onion_hosts_prefer_desc_in_alert">Gli host Onion verranno usati quando disponibili.</string>
<string name="network_use_onion_hosts_no_desc">Gli host Onion non verranno usati.</string>
<string name="network_use_onion_hosts_no_desc_in_alert">Gli host Onion non verranno usati.</string>
<string name="rate_the_app">Valuta l\'app</string>
<string name="network_use_onion_hosts_required">Obbligatorio</string>
<string name="save_servers_button">Salva</string>
<string name="saved_ICE_servers_will_be_removed">I server WebRTC ICE salvati verranno rimossi.</string>
<string name="share_link">Condividi link</string>
<string name="star_on_github">Stella su GitHub</string>
<string name="update_onion_hosts_settings_question">Aggiornare l\'impostazione degli host .onion\?</string>
<string name="network_disable_socks">Usare una connessione internet diretta\?</string>
<string name="network_use_onion_hosts">Usa gli host .onion</string>
<string name="network_enable_socks">Usare il proxy SOCKS\?</string>
<string name="network_socks_toggle">Usa il proxy SOCKS (porta 9050)</string>
<string name="use_simplex_chat_servers__question">Usare i server di <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\?</string>
<string name="using_simplex_chat_servers">Stai usando i server di <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="network_use_onion_hosts_prefer">Quando disponibili</string>
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Puoi condividere il tuo indirizzo come link o come codice QR: chiunque potrà connettersi a te. Non perderai i tuoi contatti se in seguito lo elimini.</string>
<string name="your_ICE_servers">I tuoi server ICE</string>
<string name="your_SMP_servers">I tuoi server SMP</string>
<string name="italic">corsivo</string>
<string name="callstatus_missed">chiamata persa</string>
<string name="callstate_received_answer">risposta ricevuta…</string>
<string name="callstate_received_confirmation">conferma ricevuta…</string>
<string name="callstatus_rejected">chiamata rifiutata</string>
<string name="save_and_notify_contact">Salva e avvisa il contatto</string>
<string name="save_and_notify_contacts">Salva e avvisa i contatti</string>
<string name="save_and_notify_group_members">Salva e avvisa i membri del gruppo</string>
<string name="save_preferences_question">Salvare le preferenze\?</string>
<string name="secret">segreto</string>
<string name="callstate_starting">avvio…</string>
<string name="strikethrough">barrato</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">La piattaforma di messaggistica che protegge la tua privacy e sicurezza.</string>
<string name="profile_is_only_shared_with_your_contacts">Il profilo è condiviso solo con i tuoi contatti.</string>
<string name="callstate_waiting_for_answer">in attesa di risposta…</string>
<string name="callstate_waiting_for_confirmation">in attesa di conferma…</string>
<string name="we_do_not_store_contacts_or_messages_on_servers">Non memorizziamo nessuno dei tuoi contatti o messaggi (una volta recapitati) sui server.</string>
<string name="section_title_welcome_message">MESSAGGIO DI BENVENUTO</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Puoi usare il markdown per formattare i messaggi:</string>
<string name="you_control_your_chat">Sei tu a controllare la tua chat!</string>
<string name="your_chat_profile">Il tuo profilo di chat</string>
<string name="your_profile_is_stored_on_your_device">Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo.</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti.
\n
\nI server di <xliff:g id="appName">SimpleX</xliff:g> non possono vedere il tuo profilo.</string>
<string name="ignore">Ignora</string>
<string name="immune_to_spam_and_abuse">Immune a spam e abusi</string>
<string name="incoming_audio_call">Chiamata in arrivo</string>
<string name="incoming_video_call">Videochiamata in arrivo</string>
<string name="onboarding_notifications_mode_service">Istantaneo</string>
<string name="onboarding_notifications_mode_subtitle">Può essere cambiato in seguito via impostazioni.</string>
<string name="make_private_connection">Crea una connessione privata</string>
<string name="many_people_asked_how_can_it_deliver">Molte persone hanno chiesto: <i>se <xliff:g id="appName">SimpleX</xliff:g> non ha identificatori utente, come può recapitare i messaggi\?</i></string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Solo i dispositivi client memorizzano i profili utente, i contatti, i gruppi e i messaggi inviati con <b>crittografia end-to-end a 2 livelli</b>.</string>
<string name="opensource_protocol_and_code_anybody_can_run_servers">Protocollo e codice open source: chiunque può gestire i server.</string>
<string name="paste_the_link_you_received">Incolla il link ricevuto</string>
<string name="people_can_connect_only_via_links_you_share">Le persone possono connettersi a te solo tramite i link che condividi.</string>
<string name="onboarding_notifications_mode_periodic">Periodico</string>
<string name="privacy_redefined">Privacy ridefinita</string>
<string name="onboarding_notifications_mode_title">Notifiche private</string>
<string name="read_more_in_github_with_link">Maggiori informazioni nel nostro <font color="#0088ff">repository GitHub</font>.</string>
<string name="read_more_in_github">Maggiori informazioni nel nostro repository GitHub.</string>
<string name="reject">Rifiuta</string>
<string name="first_platform_without_user_ids">La prima piattaforma senza alcun identificatore utente privata by design.</string>
<string name="next_generation_of_private_messaging">La nuova generazione di messaggistica privata</string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Per proteggere la privacy, invece degli ID utente usati da tutte le altre piattaforme, <xliff:g id="appName">SimpleX</xliff:g> dispone di identificatori per le code dei messaggi, separati per ciascuno dei tuoi contatti.</string>
<string name="use_chat">Usa la chat</string>
<string name="icon_descr_video_call">videochiamata</string>
<string name="video_call_no_encryption">videochiamata (non crittografata e2e)</string>
<string name="onboarding_notifications_mode_off">Quando l\'app è in esecuzione</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> vuole connettersi con te via</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Puoi controllare attraverso quale/i server <b>ricevere</b> i messaggi, i tuoi contatti i server che usi per inviare loro i messaggi.</string>
<string name="alert_text_skipped_messages_it_can_happen_when">Può accadere quando:
\n1. I messaggi scadono sul server se non sono stati ricevuti per 30 giorni,
\n2. Il server usato per ricevere i messaggi da questo contatto è stato aggiornato e riavviato.
\n3. La connessione è compromessa.
\nConnettiti agli sviluppatori tramite Impostazioni per ricevere aggiornamenti riguardo i server.
\nAggiungeremo la ridondanza del server per prevenire la perdita di messaggi.</string>
<string name="icon_descr_call_rejected">Chiamata rifiutata</string>
<string name="icon_descr_call_missed">Chiamata persa</string>
<string name="status_no_e2e_encryption">nessuna crittografia e2e</string>
<string name="open_verb">Apri</string>
<string name="open_simplex_chat_to_accept_call">Apri <xliff:g id="appNameFull">SimpleX Chat</xliff:g> per accettare la chiamata</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="icon_descr_call_pending_sent">Chiamata in sospeso</string>
<string name="privacy_and_security">Privacy e sicurezza</string>
<string name="protect_app_screen">Proteggi la schermata dell\'app</string>
<string name="show_call_on_lock_screen">Mostra</string>
<string name="alert_title_skipped_messages">Messaggi saltati</string>
<string name="icon_descr_speaker_off">Altoparlante spento</string>
<string name="icon_descr_speaker_on">Altoparlante acceso</string>
<string name="call_connection_via_relay">via relay</string>
<string name="icon_descr_video_off">Video off</string>
<string name="icon_descr_video_on">Video on</string>
<string name="webrtc_ice_servers">Server WebRTC ICE</string>
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> messaggio/i saltato/i</string>
<string name="your_calls">Le tue chiamate</string>
<string name="your_ice_servers">I tuoi server ICE</string>
<string name="your_privacy">La tua privacy</string>
<string name="import_database_confirmation">Importa</string>
<string name="import_database_question">Importare il database della chat\?</string>
<string name="import_database">Importa database</string>
<string name="settings_section_title_incognito">Modalità incognito</string>
<string name="settings_section_title_messages">MESSAGGI</string>
<string name="new_database_archive">Nuovo archivio database</string>
<string name="old_database_archive">Vecchio archivio del database</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Riavvia l\'app per creare un profilo di chat nuovo.</string>
<string name="restart_the_app_to_use_imported_chat_database">Riavvia l\'app per usare il database della chat importato.</string>
<string name="run_chat_section">AVVIA CHAT</string>
<string name="send_link_previews">Invia anteprime dei link</string>
<string name="set_password_to_export">Imposta la password per esportare</string>
<string name="settings_section_title_settings">IMPOSTAZIONI</string>
<string name="settings_section_title_socks">PROXY SOCKS</string>
<string name="stop_chat_confirmation">Ferma</string>
<string name="stop_chat_question">Fermare la chat\?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Ferma la chat per esportare, importare o eliminare il database della chat. Non potrai ricevere e inviare messaggi mentre la chat è ferma.</string>
<string name="settings_section_title_support">SUPPORTA SIMPLEX CHAT</string>
<string name="settings_section_title_themes">TEMI</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile.</string>
<string name="transfer_images_faster">Trasferisci immagini più velocemente</string>
<string name="settings_section_title_you">TU</string>
<string name="your_chat_database">Il tuo database della chat</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Il tuo attuale database di chat verrà ELIMINATO e SOSTITUITO con quello importato.
\nQuesta azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile.</string>
<string name="alert_title_group_invitation_expired">Invito scaduto!</string>
<string name="group_invitation_item_description">invito al gruppo <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="icon_descr_add_members">Invita membri</string>
<string name="join_group_button">Entra</string>
<string name="join_group_question">Entrare nel gruppo\?</string>
<string name="join_group_incognito_button">Entra in incognito</string>
<string name="joining_group">Ingresso nel gruppo</string>
<string name="keychain_error">Errore del portachiavi</string>
<string name="leave_group_button">Esci</string>
<string name="leave_group_question">Uscire dal gruppo\?</string>
<string name="open_chat">Apri chat</string>
<string name="restore_passphrase_not_found_desc">Password non trovata nel Keystore, inseriscila a mano. Potrebbe essere successo se hai ripristinato i dati dell\'app usando uno strumento di backup. In caso contrario, contatta gli sviluppatori.</string>
<string name="restore_database_alert_desc">Inserisci la password precedente dopo aver ripristinato il backup del database. Questa azione non può essere annullata.</string>
<string name="store_passphrase_securely_without_recover">Conserva la password in modo sicuro, NON potrai accedere alla chat se la perdi.</string>
<string name="restore_database_alert_confirm">Ripristina</string>
<string name="restore_database">Ripristina backup del database</string>
<string name="restore_database_alert_title">Ripristinare il backup del database\?</string>
<string name="database_restore_error">Errore di ripristino del database</string>
<string name="save_archive">Salva archivio</string>
<string name="save_passphrase_and_open_chat">Salva la password e apri la chat</string>
<string name="database_backup_can_be_restored">Il tentativo di cambiare la password del database non è stato completato.</string>
<string name="unknown_database_error_with_info">Errore del database sconosciuto: %s</string>
<string name="unknown_error">Errore sconosciuto</string>
<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="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">invitato</string>
<string name="rcv_group_event_invited_via_your_group_link">invitato via link del tuo gruppo</string>
<string name="rcv_group_event_member_added">invitato <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_left">uscito/a</string>
<string name="group_member_status_left">uscito/a</string>
<string name="group_member_role_member">membro</string>
<string name="group_member_role_owner">proprietario</string>
<string name="group_member_status_removed">rimosso</string>
<string name="rcv_group_event_member_deleted">rimosso <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">sei stato/a rimosso/a</string>
<string name="group_invitation_tap_to_join">Tocca per entrare</string>
<string name="group_invitation_tap_to_join_incognito">Toccare per entrare in incognito</string>
<string name="alert_message_no_group">Questo gruppo non esiste più.</string>
<string name="rcv_group_event_updated_group_profile">profilo del gruppo aggiornato</string>
<string name="you_are_invited_to_group">Sei stato/a invitato/a al gruppo</string>
<string name="snd_conn_event_switch_queue_phase_completed">hai cambiato indirizzo</string>
<string name="snd_conn_event_switch_queue_phase_completed_for_member">hai cambiato l\'indirizzo per %s</string>
<string name="snd_group_event_changed_role_for_yourself">hai cambiato il tuo ruolo in %s</string>
<string name="snd_group_event_changed_member_role">hai cambiato il ruolo di %s in %s</string>
<string name="you_joined_this_group">Sei entrato/a in questo gruppo</string>
<string name="snd_group_event_user_left">sei uscito/a</string>
<string name="you_rejected_group_invitation">Hai rifiutato l\'invito al gruppo</string>
<string name="snd_group_event_member_deleted">hai rimosso <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="alert_title_cant_invite_contacts_descr">Stai usando un profilo in incognito per questo gruppo: per impedire la condivisione del tuo profilo principale non è consentito invitare contatti</string>
<string name="you_sent_group_invitation">Hai inviato un invito al gruppo</string>
<string name="button_add_members">Invita membri</string>
<string name="invite_to_group_button">Invita al gruppo</string>
<string name="button_leave_group">Esci dal gruppo</string>
<string name="info_row_local_name">Nome locale</string>
<string name="member_info_section_title_member">MEMBRO</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Il membro verrà rimosso dal gruppo, non è reversibile!</string>
<string name="new_member_role">Nuovo ruolo del membro</string>
<string name="no_contacts_selected">Nessun contatto selezionato</string>
<string name="no_contacts_to_add">Nessun contatto da aggiungere</string>
<string name="only_group_owners_can_change_prefs">Solo i proprietari del gruppo possono modificarne le preferenze.</string>
<string name="remove_member_confirmation">Rimuovi</string>
<string name="button_remove_member">Rimuovi membro</string>
<string name="role_in_group">Ruolo</string>
<string name="select_contacts">Seleziona i contatti</string>
<string name="button_send_direct_message">Invia messaggio diretto</string>
<string name="skip_inviting_button">Salta l\'invito di membri</string>
<string name="switch_verb">Cambia</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contatto/i selezionato/i</string>
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRI</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Puoi condividere un link o un codice QR: chiunque potrà unirsi al gruppo. Non perderai i membri del gruppo se in seguito lo elimini.</string>
<string name="invite_prohibited_description">Stai tentando di invitare un contatto con cui hai condiviso un profilo in incognito nel gruppo in cui stai usando il tuo profilo principale</string>
<string name="group_info_member_you">tu: <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="incognito">Incognito</string>
<string name="group_unsupported_incognito_main_profile_sent">La modalità in incognito non è supportata qui: il tuo profilo principale verrà inviato ai membri del gruppo</string>
<string name="incognito_info_protects">La modalità in incognito protegge la privacy del nome e dell\'immagine del tuo profilo principale: per ogni nuovo contatto viene creato un nuovo profilo casuale.</string>
<string name="conn_level_desc_indirect">indiretta (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<string name="incognito_info_allows">Permette di avere molte connessioni anonime senza dati condivisi tra di loro in un unico profilo di chat.</string>
<string name="theme_light">Chiaro</string>
<string name="network_status">Stato della rete</string>
<string name="network_option_ping_interval">Intervallo PING</string>
<string name="network_option_protocol_timeout">Scadenza del protocollo</string>
<string name="receiving_via">Ricezione via</string>
<string name="network_options_reset_to_defaults">Ripristina i predefiniti</string>
<string name="network_options_revert">Annulla</string>
<string name="network_options_save">Salva</string>
<string name="save_group_profile">Salva il profilo del gruppo</string>
<string name="network_option_seconds_label">sec</string>
<string name="sending_via">Invio tramite</string>
<string name="conn_stats_section_title_servers">SERVER</string>
<string name="switch_receiving_address">Cambia indirizzo di ricezione</string>
<string name="theme_system">Sistema</string>
<string name="network_option_tcp_connection_timeout">Scadenza connessione TCP</string>
<string name="group_is_decentralized">Il gruppo è completamente decentralizzato: è visibile solo ai membri.</string>
<string name="member_role_will_be_changed_with_notification">Il ruolo verrà cambiato in \"%s\". Tutti i membri del gruppo riceveranno una notifica.</string>
<string name="member_role_will_be_changed_with_invitation">Il ruolo verrà cambiato in \"%s\". Il membro riceverà un nuovo invito.</string>
<string name="incognito_info_find">Per trovare il profilo usato per una connessione in incognito, tocca il nome del contatto o del gruppo in cima alla chat.</string>
<string name="update_network_settings_confirmation">Aggiorna</string>
<string name="update_network_settings_question">Aggiornare le impostazioni di rete\?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">L\'aggiornamento delle impostazioni riconnetterà il client a tutti i server.</string>
<string name="incognito_info_share">Quando condividi un profilo in incognito con qualcuno, questo profilo verrà utilizzato per i gruppi a cui ti invitano.</string>
<string name="group_main_profile_sent">Il tuo profilo di chat verrà inviato ai membri del gruppo</string>
<string name="incognito_random_profile">Il tuo profilo casuale</string>
<string name="message_deletion_prohibited">L\'eliminazione irreversibile dei messaggi è vietata in questa chat.</string>
<string name="chat_preferences_no">no</string>
<string name="chat_preferences_off">off</string>
<string name="feature_off">off</string>
<string name="chat_preferences_on">on</string>
<string name="only_you_can_delete_messages">Solo tu puoi eliminare irreversibilmente i messaggi (il tuo contatto può contrassegnarli per l\'eliminazione).</string>
<string name="only_you_can_send_disappearing">Solo tu puoi inviare messaggi a tempo.</string>
<string name="only_your_contact_can_delete">Solo il tuo contatto può eliminare irreversibilmente i messaggi (tu puoi contrassegnarli per l\'eliminazione).</string>
<string name="only_your_contact_can_send_disappearing">Solo il tuo contatto può inviare messaggi a tempo.</string>
<string name="prohibit_sending_disappearing_messages">Proibisci l\'invio di messaggi a tempo.</string>
<string name="prohibit_sending_voice_messages">Proibisci l\'invio di messaggi vocali.</string>
<string name="feature_received_prohibited">ricevuto, vietato</string>
<string name="reset_color">Ripristina i colori</string>
<string name="save_color">Salva colore</string>
<string name="accept_feature_set_1_day">Imposta 1 giorno</string>
<string name="set_group_preferences">Imposta le preferenze del gruppo</string>
<string name="theme">Tema</string>
<string name="voice_messages">Messaggi vocali</string>
<string name="chat_preferences_yes"></string>
<string name="chat_preferences_you_allow">Lo consenti</string>
<string name="your_preferences">Le tue preferenze</string>
<string name="v4_3_improved_server_configuration">Configurazione del server migliorata</string>
<string name="v4_3_irreversible_message_deletion">Eliminazione irreversibile del messaggio</string>
<string name="message_deletion_prohibited_in_chat">L\'eliminazione irreversibile dei messaggi è vietata in questo gruppo.</string>
<string name="v4_3_voice_messages_desc">Max 40 secondi, ricevuto istantaneamente.</string>
<string name="new_in_version">Novità nella %s</string>
<string name="only_you_can_send_voice">Solo tu puoi inviare messaggi vocali.</string>
<string name="only_your_contact_can_send_voice">Solo il tuo contatto può inviare messaggi vocali.</string>
<string name="prohibit_message_deletion">Proibisci l\'eliminazione irreversibile dei messaggi.</string>
<string name="prohibit_direct_messages">Proibisci l\'invio di messaggi diretti ai membri.</string>
<string name="prohibit_sending_disappearing">Proibisci l\'invio di messaggi a tempo.</string>
<string name="prohibit_sending_voice">Proibisci l\'invio di messaggi vocali.</string>
<string name="v4_2_security_assessment">Valutazione della sicurezza</string>
<string name="v4_2_security_assessment_desc">La sicurezza di SimpleX Chat è stata verificata da Trail of Bits.</string>
<string name="v4_3_voice_messages">Messaggi vocali</string>
<string name="voice_prohibited_in_this_chat">I messaggi vocali sono vietati in questa chat.</string>
<string name="voice_messages_are_prohibited">I messaggi vocali sono vietati in questo gruppo.</string>
<string name="whats_new">Novità</string>
<string name="v4_2_auto_accept_contact_requests_desc">Con messaggio di benvenuto facoltativo.</string>
<string name="v4_3_irreversible_message_deletion_desc">I tuoi contatti possono consentire l\'eliminazione completa dei messaggi.</string>
<string name="v4_3_improved_privacy_and_security">Privacy e sicurezza migliorate</string>
<string name="v4_4_live_messages">Messaggi in diretta</string>
<string name="v4_4_live_messages_desc">I destinatari vedono gli aggiornamenti mentre li digiti.</string>
<string name="v4_4_disappearing_messages_desc">I messaggi inviati verranno eliminati dopo il tempo impostato.</string>
<string name="v4_4_verify_connection_security">Verifica la sicurezza della connessione</string>
<string name="chat_item_ttl_none">mai</string>
<string name="new_passphrase">Nuova password…</string>
<string name="no_received_app_files">Nessun file ricevuto o inviato</string>
<string name="notifications_will_be_hidden">Le notifiche verranno mostrate solo fino all\'arresto dell\'app!</string>
<string name="enter_correct_current_passphrase">Inserisci la password attuale corretta.</string>
<string name="store_passphrase_securely">Conserva la password in modo sicuro, NON potrai cambiarla se la perdi.</string>
<string name="remove_passphrase">Rimuovi</string>
<string name="remove_passphrase_from_keychain">Rimuovere la password dal Keystore\?</string>
<string name="save_passphrase_in_keychain">Salva la password nel Keystore</string>
<string name="chat_item_ttl_seconds">%s secondo/i</string>
<string name="stop_chat_to_enable_database_actions">Ferma la chat per attivare le azioni del database.</string>
<string name="delete_files_and_media_desc">Questa azione non può essere annullata: tutti i file e i media ricevuti e inviati verranno eliminati. Rimarranno le immagini a bassa risoluzione.</string>
<string name="enable_automatic_deletion_message">Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti.</string>
<string name="update_database">Aggiorna</string>
<string name="update_database_passphrase">Aggiorna la password del database</string>
<string name="you_have_to_enter_passphrase_every_time">Devi inserire la password ogni volta che si avvia l\'app: non viene memorizzata sul dispositivo.</string>
<string name="you_must_use_the_most_recent_version_of_database">Devi usare la versione più recente del tuo database della chat SOLO su un dispositivo, altrimenti potresti non ricevere più i messaggi da alcuni contatti.</string>
<string name="database_is_not_encrypted">Il database della chat non è crittografato: imposta la password per proteggerlo.</string>
<string name="icon_descr_cancel_live_message">Annulla messaggio in diretta</string>
<string name="feature_offered_item">offerto %s</string>
<string name="feature_offered_item_with_param">offerto %s: %2s</string>
<string name="feature_cancelled_item">annullato %s</string>
</resources>
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
<string name="thousand_abbreviation">т</string>
<!-- Connect via Link - MainActivity.kt -->
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
@@ -10,7 +9,6 @@
<string name="profile_will_be_sent_to_contact_sending_link">Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку.</string>
<string name="you_will_join_group">Вы вступите в группу, на которую ссылается эта ссылка.</string>
<string name="connect_via_link_verb">Соединиться</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">соединено</string>
<string name="server_error">ошибка</string>
@@ -18,7 +16,6 @@
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">удалено</string>
<string name="marked_deleted_description">помечено к удалению</string>
@@ -27,7 +24,6 @@
<string name="sender_you_pronoun">вы</string>
<string name="unknown_message_format">неизвестный формат сообщения</string>
<string name="invalid_message_format">неверный формат сообщения</string>
<!-- PendingContactConnection - ChatModel.kt -->
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
<string name="display_name_connection_established">соединение установлено</string>
@@ -41,7 +37,6 @@
<string name="description_via_contact_address_link_incognito">инкогнито через ссылку-контакт</string>
<string name="description_via_one_time_link">через одноразовую ссылку</string>
<string name="description_via_one_time_link_incognito">инкогнито через одноразовую ссылку</string>
<!-- FormattedText, SimpleX links - ChatModel.kt -->
<string name="simplex_link_contact">SimpleX ссылка-контакт</string>
<string name="simplex_link_invitation">SimpleX одноразовая ссылка</string>
@@ -52,12 +47,10 @@
<string name="simplex_link_mode_full">Полная ссылка</string>
<string name="simplex_link_mode_browser">В браузере</string>
<string name="simplex_link_mode_browser_warning">Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными.</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
<string name="error_setting_network_config">Ошибка при сохранении настроек сети</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Превышено время соединения</string>
<string name="connection_error">Ошибка соединения</string>
@@ -90,7 +83,6 @@
<string name="smp_server_test_secure_queue">Защита очереди</string>
<string name="smp_server_test_delete_queue">Удаление очереди</string>
<string name="smp_server_test_disconnect">Разрыв соединения</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
<string name="service_notifications">Мгновенные уведомления!</string>
@@ -106,16 +98,13 @@
<string name="enter_passphrase_notification_desc">Для получения уведомлений, пожалуйста, введите пароль от базы данных</string>
<string name="database_initialization_error_title">Ошибка базы данных</string>
<string name="database_initialization_error_desc">Ошибка при инициализации базы данных. Нажмите чтобы узнать больше</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
<string name="simplex_service_notification_text">Приём сообщений…</string>
<string name="hide_notification">Скрыть</string>
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
<string name="settings_notification_preview_mode_title">Показывать уведомления</string>
@@ -136,12 +125,10 @@
<string name="notification_preview_new_message">новое сообщение</string>
<string name="notification_new_contact_request">Новый запрос на соединение</string>
<string name="notification_contact_connected">Соединен(а)</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
<string name="la_notice_turn_on">Включить</string>
<!-- LocalAuthentication.kt -->
<string name="auth_simplex_lock_turned_on">Блокировка SimpleX включена</string>
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
@@ -155,11 +142,9 @@
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
<string name="auth_stop_chat">Остановить чат</string>
<string name="auth_open_chat_console">Открыть консоль</string>
<!-- Chat Alerts - ChatItemView.kt -->
<string name="message_delivery_error_title">Ошибка доставки сообщения</string>
<string name="message_delivery_error_desc">Скорее всего, этот контакт удалил соединение с вами.</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Ответить</string>
<string name="share_verb">Поделиться</string>
@@ -175,14 +160,12 @@
<string name="delete_message_mark_deleted_warning">Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение.</string>
<string name="for_me_only">Удалить для меня</string>
<string name="for_everybody">Для всех</string>
<!-- CIMetaView.kt -->
<string name="icon_descr_edited">отредактировано</string>
<string name="icon_descr_sent_msg_status_sent">отправлено</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">ошибка авторизации при отправке</string>
<string name="icon_descr_sent_msg_status_send_failed">ошибка при отправке</string>
<string name="icon_descr_received_msg_status_unread">не прочитано</string>
<!-- ChatListView.kt -->
<string name="personal_welcome">Здравствуйте <xliff:g>%1$s</xliff:g>!</string>
<string name="welcome">Здравствуйте!</string>
@@ -195,12 +178,10 @@
<string name="tap_to_start_new_chat">Нажмите, чтобы начать чат</string>
<string name="chat_with_developers">Соединиться с разработчиками</string>
<string name="you_have_no_chats">У вас нет чатов</string>
<!-- ShareListView.kt -->
<string name="share_message">Отправить сообщение…</string>
<string name="share_image">Отправить изображение…</string>
<string name="share_file">Отправить файл…</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Прикрепить</string>
<string name="icon_descr_context">Значок контекста</string>
@@ -210,7 +191,6 @@
<string name="images_limit_desc">Только 10 изображений могут быть отправлены одномоментно</string>
<string name="image_decoding_exception_title">Ошибка декодирования</string>
<string name="image_decoding_exception_desc">Не получается декодировать изображение. Пожалуйста, попробуйте другое изображение или свяжитесь с разработчиками.</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Изображение</string>
<string name="icon_descr_waiting_for_image">Ожидается прием изображения</string>
@@ -219,7 +199,6 @@
<string name="waiting_for_image">Ожидается прием изображения</string>
<string name="image_will_be_received_when_contact_is_online">Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
<string name="image_saved">Изображение сохранено в Галерею</string>
<!-- Files - CIFileView.kt -->
<string name="icon_descr_file">Файл</string>
<string name="large_file">Большой файл!</string>
@@ -230,15 +209,12 @@
<string name="file_saved">Файл сохранен</string>
<string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Voice messages -->
<string name="voice_message">Голосовое сообщение</string>
<string name="voice_message_with_duration">Голосовое сообщение (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="voice_message_send_text">Голосовое сообщение…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Уведомления</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact_question">Удалить контакт?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
@@ -250,7 +226,6 @@
<string name="icon_descr_server_status_pending">Ожидается соединение с сервером</string>
<string name="switch_receiving_address_question">Переключить адрес получения?</string>
<string name="switch_receiving_address_desc">Это экспериментальная функция! Она будет работать, только если на другом клиенте установлена версия 4.2. После завершения смены адреса вы увидите сообщение — убедитесь, что вы все еще можете получать сообщения от этого контакта (или члена группы).</string>
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Отправить сообщение</string>
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
@@ -259,7 +234,6 @@
<string name="voice_messages_prohibited">Голосовые сообщения запрещены!</string>
<string name="ask_your_contact_to_enable_voice">Попросите вашего контакта разрешить отправку голосовых сообщений.</string>
<string name="only_group_owners_can_enable_voice">Только владельцы группы могут разрешить голосовые сообщения.</string>
<!-- General Actions / Responses -->
<string name="back">Назад</string>
<string name="cancel_verb">Отменить</string>
@@ -269,7 +243,6 @@
<string name="no_details">нет описания</string>
<string name="add_contact">Одноразовая ссылка</string>
<string name="copied">Скопировано в буфер обмена</string>
<!-- NewChatSheet -->
<string name="add_contact_or_create_group">Начать новый разговор</string>
<string name="share_one_time_link">Создать ссылку-приглашение</string>
@@ -279,13 +252,11 @@
<string name="to_share_with_your_contact">(чтобы отправить вашему контакту)</string>
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(сканировать или вставить из буфера)</string>
<string name="only_stored_on_members_devices">(хранится только у членов группы)</string>
<!-- GetImageView -->
<string name="toast_permission_denied">Разрешение не получено!</string>
<string name="use_camera_button">Камера</string>
<string name="from_gallery_button">Галерея</string>
<string name="choose_file">Файлы</string>
<!-- help - ChatHelpView.kt -->
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="you_can_connect_to_simplex_chat_founder">Вы можете <font color="#0088ff">соединиться с разработчиками</font>, чтобы задать любые вопросы или получать уведомления о новых версиях.</string>
@@ -298,14 +269,12 @@
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Если вы получили ссылку с приглашением из <xliff:g id="appName">SimpleX Chat</xliff:g>, вы можете открыть ее в браузере:</string>
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 на компьютере: сосканируйте показанный QR код из приложения через <b>Сканировать QR код</b>.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 на мобильном: намжите кнопку <b>Open in mobile app</b> на веб странице, затем нажмите <b>Соединиться</b> в приложении.</string>
<!-- Contact Request Alert Dialogue - CharListNavLinkView.kt -->
<string name="accept_connection_request__question">Принять запрос на соединение?</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение.</string>
<string name="accept_contact_button">Принять</string>
<string name="accept_contact_incognito_button">Принять инкогнито</string>
<string name="reject_contact_button">Отклонить</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
<string name="clear_chat_question">Очистить чат?</string>
<string name="clear_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</string>
@@ -317,29 +286,23 @@
<string name="mark_read">Прочитано</string>
<string name="mark_unread">Не прочитано</string>
<string name="set_contact_name">Имя контакта</string>
<!-- Actions - ChatListNavLinkView.kt -->
<string name="mute_chat">Без звука</string>
<string name="unmute_chat">Уведомлять</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
<string name="delete_pending_connection__question">Удалить ожидаемое соединение?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому вы отправили эту ссылку, не сможет соединиться!</string>
<string name="connection_you_accepted_will_be_cancelled">Подтвержденное соединение будет отменено!</string>
<!-- Connection Pending Alert Dialogue - ChatListNavLinkView.kt -->
<string name="alert_title_contact_connection_pending">Соединение еще не установлено!</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).</string>
<!-- Contact Request Information - ContactRequestView.kt -->
<string name="contact_wants_to_connect_with_you">хочет соединиться с вами!</string>
<!-- Image Placeholder - ChatInfoImage.kt -->
<string name="icon_descr_profile_image_placeholder">аватар не установлен</string>
<string name="image_descr_profile_image">аватар</string>
<!-- Content Descriptions -->
<string name="icon_descr_close_button">закрыть</string>
<string name="image_descr_link_preview">изображение превью ссылки</string>
@@ -352,10 +315,8 @@
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> логотип</string>
<string name="icon_descr_email">Email</string>
<string name="icon_descr_more_button">Больше</string>
<!-- Connection info - ContactConnectionInfoView.kt -->
<string name="show_QR_code">Показать QR код</string>
<!-- Add Contact - AddContactView.kt -->
<string name="invalid_QR_code">Неверный QR код</string>
<string name="this_QR_code_is_not_a_link">Этот QR код не является ссылкой!</string>
@@ -372,16 +333,13 @@
<string name="share_invitation_link">Поделиться ссылкой</string>
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
<string name="your_profile_will_be_sent">Ваш профиль будет отправлен вашему контакту</string>
<!-- PasteToConnect.kt -->
<string name="connect_button">Соединиться</string>
<string name="paste_button">Вставить</string>
<!-- CreateLinkView.kt -->
<string name="create_one_time_link">Создать одноразовую ссылку</string>
<string name="one_time_link">Одноразовая ссылка</string>
<string name="your_contact_address">Ваш SimpleX адрес</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Настройки</string>
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
@@ -450,7 +408,6 @@
<string name="network_use_onion_hosts_no_desc_in_alert">Onion хосты не используются.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Подключаться только к onion хостам.</string>
<string name="appearance_settings">Интерфейс</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Создать адрес</string>
<string name="delete_address__question">Удалить адрес?</string>
@@ -458,13 +415,11 @@
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</string>
<string name="share_link">Поделиться\nссылкой</string>
<string name="delete_address">Удалить\nадрес</string>
<!-- AcceptRequestsView.kt -->
<string name="contact_requests">Запросы контактов</string>
<string name="accept_requests">Принимать запросы</string>
<string name="accept_automatically">Автоматически</string>
<string name="section_title_welcome_message">ПРИВЕТСТВЕННОЕ СООБЩЕНИЕ</string>
<!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Имя профиля:</string>
<string name="full_name__field">"Полное имя:</string>
@@ -477,7 +432,6 @@
<string name="save_and_notify_contacts">Сохранить и уведомить контакты</string>
<string name="save_and_notify_group_members">Сохранить и уведомить членов группы</string>
<string name="exit_without_saving">Выйти без сохранения</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность.</string>
@@ -490,7 +444,6 @@
<string name="full_name_optional__prompt">Полное имя (не обязательно)</string>
<string name="create_profile_button">Создать</string>
<string name="about_simplex">О SimpleX</string>
<!-- markdown demo - MarkdownHelpView.kt -->
<string name="how_to_use_markdown">Как форматировать</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Вы можете форматировать сообщения:</string>
@@ -503,7 +456,6 @@
<string name="connect_via_link">Соединиться через ссылку</string>
<string name="this_string_is_not_a_connection_link">Эта строка не является ссылкой-приглашением!</string>
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Открыть в приложении</b>.</string>
<!-- CICallStatus -->
<string name="callstatus_calling">входящий звонок…</string>
<string name="callstatus_missed">пропущенный звонок</string>
@@ -513,7 +465,6 @@
<string name="callstatus_in_progress">активный звонок</string>
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">ошибка звонка</string>
<!-- CallState -->
<string name="callstate_starting">инициализация…</string>
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
@@ -523,7 +474,6 @@
<string name="callstate_connecting">соединяется…</string>
<string name="callstate_connected">соединено</string>
<string name="callstate_ended">завершен</string>
<!-- SimpleXInfo -->
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
<string name="privacy_redefined">Более конфиденциальный</string>
@@ -535,7 +485,6 @@
<string name="create_your_profile">Создать профиль</string>
<string name="make_private_connection">Добавьте контакт</string>
<string name="how_it_works">Как это работает</string>
<!-- How SimpleX Works -->
<string name="how_simplex_works">Как <xliff:g id="appName">SimpleX</xliff:g> работает</string>
<string name="many_people_asked_how_can_it_deliver">Много пользователей спросили: <i>как <xliff:g id="appName">SimpleX</xliff:g> доставляет сообщения без идентификаторов пользователей?</i></string>
@@ -544,10 +493,10 @@
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются <b>с двухуровневым end-to-end шифрованием</b>.</string>
<string name="read_more_in_github">Узнайте больше из нашего GitHub репозитория.</string>
<string name="read_more_in_github_with_link">Узнайте больше из нашего <font color="#0088ff">GitHub репозитория</font>.</string>
<!-- SetNotificationsMode.kt -->
<string name="use_chat">Использовать чат</string>
<!-- MakeConnection -->
<string name="paste_the_link_you_received">Вставить полученную ссылку</string>
<!-- Call -->
<string name="incoming_video_call">Входящий видеозвонок</string>
<string name="incoming_audio_call">Входящий аудиозвонок</string>
@@ -562,7 +511,6 @@
<string name="call_already_ended">Звонок уже завершен!</string>
<string name="icon_descr_video_call">видеозвонок</string>
<string name="icon_descr_audio_call">аудиозвонок</string>
<!-- Call settings -->
<string name="settings_audio_video_calls">Аудио- и видеозвонки</string>
<string name="your_calls">Ваши звонки</string>
@@ -573,12 +521,10 @@
<string name="no_call_on_lock_screen">Выключить</string>
<string name="your_ice_servers">Ваши ICE серверы</string>
<string name="webrtc_ice_servers">WebRTC ICE серверы</string>
<!-- Call Lock Screen -->
<string name="open_simplex_chat_to_accept_call">Откройте <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\nчтобы принять звонок</string>
<string name="allow_accepting_calls_from_lock_screen">Вы можете разрешить принимать звонки на экране блокировки через Настройки.</string>
<string name="open_verb">Открыть</string>
<!-- Call overlay -->
<string name="status_e2e_encrypted">e2e зашифровано</string>
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
@@ -594,7 +540,6 @@
<string name="icon_descr_speaker_off">Выключить спикер</string>
<string name="icon_descr_speaker_on">Включить спикер</string>
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
<!-- Call items -->
<string name="icon_descr_call_pending_sent">Входящий звонок</string>
<string name="icon_descr_call_missed">Пропущенный звонок</string>
@@ -603,7 +548,6 @@
<string name="icon_descr_call_progress">Текущий звонок</string>
<string name="icon_descr_call_ended">Звонок завершен</string>
<string name="answer_call">Принять звонок</string>
<!-- Message integrity -->
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> пропущенных сообщений</string>
<string name="integrity_msg_bad_hash">ошибка хэш сообщения</string>
@@ -611,7 +555,6 @@
<string name="integrity_msg_duplicate">повторное сообщение</string>
<string name="alert_title_skipped_messages">Пропущенные сообщения</string>
<string name="alert_text_skipped_messages_it_can_happen_when">Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</string>
<!-- Privacy settings -->
<string name="privacy_and_security">Конфиденциальность</string>
<string name="your_privacy">Конфиденциальность</string>
@@ -620,7 +563,6 @@
<string name="transfer_images_faster">Передавать изображения быстрее</string>
<string name="send_link_previews">Отправлять картинки ссылок</string>
<string name="full_backup">Резервная копия данных</string>
<!-- Settings sections -->
<string name="settings_section_title_you">ВЫ</string>
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
@@ -637,7 +579,6 @@
<string name="settings_section_title_messages">СООБЩЕНИЯ</string>
<string name="settings_section_title_calls">ЗВОНКИ</string>
<string name="settings_section_title_incognito">Режим Инкогнито</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">База данных</string>
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
@@ -687,7 +628,6 @@
<string name="enable_automatic_deletion_message">Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут.</string>
<string name="delete_messages">Удалить сообщения</string>
<string name="error_changing_message_deletion">Ошибка при изменении настройки</string>
<!-- DatabaseEncryptionView.kt -->
<string name="save_passphrase_in_keychain">Сохранить пароль в Keystore</string>
<string name="database_encrypted">База данных зашифрована!</string>
@@ -716,7 +656,6 @@
<string name="database_passphrase_will_be_updated">Пароль базы данных будет изменен.</string>
<string name="store_passphrase_securely">Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</string>
<string name="store_passphrase_securely_without_recover">Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.</string>
<!-- DatabaseErrorView.kt -->
<string name="wrong_passphrase">Неправильный пароль базы данных</string>
<string name="encrypted_database">База данных зашифрована</string>
@@ -741,11 +680,9 @@
<string name="restore_database_alert_confirm">Восстановить</string>
<string name="database_restore_error">Ошибка при восстановлении базы данных</string>
<string name="restore_passphrase_not_found_desc">Пароль не найден в Keystore, пожалуйста, введите его вручную. Это могло произойти, если вы восстановили данные приложения с помощью инструмента резервного копирования. Если это не так, пожалуйста, свяжитесь с разработчиками.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Чат остановлен</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Вы можете запустить чат через Настройки приложения или перезапустив приложение.</string>
<!-- ChatArchiveView.kt -->
<string name="chat_archive_header">Архив чата</string>
<string name="chat_archive_section">АРХИВ ЧАТА</string>
@@ -753,7 +690,6 @@
<string name="delete_archive">Удалить архив</string>
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Удалить архив чата?</string>
<!-- Groups -->
<string name="group_invitation_item_description">приглашение в группу <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Вступить в группу?</string>
@@ -773,7 +709,6 @@
<string name="alert_message_no_group">Эта группа больше не существует.</string>
<string name="alert_title_cant_invite_contacts">Нельзя пригласить контакты!</string>
<string name="alert_title_cant_invite_contacts_descr">Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
@@ -782,7 +717,6 @@
<string name="you_joined_this_group">Вы вступили в эту группу</string>
<string name="you_rejected_group_invitation">Вы отклонили приглашение в группу</string>
<string name="group_invitation_expired">Приглашение в группу истекло</string>
<!-- Group event chat items -->
<string name="rcv_group_event_member_added">пригласил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">соединен(а)</string>
@@ -799,7 +733,6 @@
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">вы покинули группу</string>
<string name="snd_group_event_group_profile_updated">профиль группы обновлен</string>
<!-- Conn event chat items -->
<string name="rcv_conn_event_switch_queue_phase_completed">поменял(а) адрес для вас</string>
<string name="rcv_conn_event_switch_queue_phase_changing">смена адреса…</string>
@@ -807,12 +740,10 @@
<string name="snd_conn_event_switch_queue_phase_changing_for_member">смена адреса для %s…</string>
<string name="snd_conn_event_switch_queue_phase_completed">вы поменяли адрес</string>
<string name="snd_conn_event_switch_queue_phase_changing">смена адреса…</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">член группы</string>
<string name="group_member_role_admin">админ</string>
<string name="group_member_role_owner">владелец</string>
<!-- GroupMemberStatus -->
<string name="group_member_status_removed">удален(а)</string>
<string name="group_member_status_left">покинул(а)</string>
@@ -825,9 +756,7 @@
<string name="group_member_status_connected">соединен(а)</string>
<string name="group_member_status_complete">соединение завершено</string>
<string name="group_member_status_creator">создатель</string>
<string name="group_member_status_connecting">соединяется</string>
<!-- AddGroupMembersView.kt -->
<string name="no_contacts_to_add">Нет контактов для добавления</string>
<string name="new_member_role">Роль члена группы</string>
@@ -841,7 +770,6 @@
<string name="no_contacts_selected">Контакты не выбраны</string>
<string name="invite_prohibited">Нельзя пригласить контакт!</string>
<string name="invite_prohibited_description">Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Пригласить членов группы</string>
<string name="group_info_section_title_num_members">ЧЛЕНОВ ГРУППЫ: <xliff:g id="num_members">%1$s</xliff:g></string>
@@ -861,12 +789,10 @@
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
<string name="only_group_owners_can_change_prefs">Только владельцы группы могут изменять предпочтения группы.</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
<string name="info_row_local_name">Локальное имя</string>
<string name="info_row_database_id">ID базы данных</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Удалить члена группы</string>
<string name="button_send_direct_message">Отправить сообщение</string>
@@ -886,14 +812,12 @@
<string name="info_row_connection">Соединение</string>
<string name="conn_level_desc_direct">прямое</string>
<string name="conn_level_desc_indirect">непрямое (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<!-- ConnectionStats -->
<string name="conn_stats_section_title_servers">СЕРВЕРЫ</string>
<string name="receiving_via">Получение через</string>
<string name="sending_via">Отправка через</string>
<string name="network_status">Состояние сети</string>
<string name="switch_receiving_address">Переключить адрес получения</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Создать скрытую группу</string>
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только членам.</string>
@@ -901,12 +825,10 @@
<string name="group_full_name_field">Полное имя:</string>
<string name="group_unsupported_incognito_main_profile_sent">Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы</string>
<string name="group_main_profile_sent">Ваш профиль чата будет отправлен членам группы</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах членов, а не на серверах.</string>
<string name="save_group_profile">Сохранить профиль группы</string>
<string name="error_saving_group_profile">Ошибка при сохранении профиля группы</string>
<!-- AdvancedNetworkSettings.kt -->
<string name="network_options_reset_to_defaults">Сбросить настройки</string>
<string name="network_option_seconds_label">сек</string>
@@ -919,29 +841,24 @@
<string name="update_network_settings_question">Обновить настройки сети?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">Обновление настроек приведет к переподключению клиента ко всем серверам.</string>
<string name="update_network_settings_confirmation">Обновить</string>
<!-- Incognito mode -->
<string name="incognito">Инкогнито</string>
<string name="incognito_random_profile">Случайный профиль</string>
<string name="incognito_random_profile_description">Вашему контакту будет отправлен случайный профиль</string>
<string name="incognito_random_profile_from_contact_description">Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль</string>
<string name="incognito_info_protects">Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</string>
<string name="incognito_info_allows">Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</string>
<string name="incognito_info_share">Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</string>
<string name="incognito_info_find">Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.</string>
<!-- Default themes -->
<string name="theme_system">Системная</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Темная</string>
<!-- Appearance.kt -->
<string name="theme">Тема</string>
<string name="save_color">Сохранить цвет</string>
<string name="reset_color">Сбросить цвета</string>
<string name="color_primary">Акцент</string>
<!-- Preferences.kt -->
<string name="chat_preferences_you_allow">Вы разрешаете</string>
<string name="chat_preferences_contact_allows">Контакт разрешает</string>
@@ -990,5 +907,90 @@
<string name="message_deletion_prohibited_in_chat">Необратимое удаление сообщений запрещено в этой группе.</string>
<string name="group_members_can_send_voice">Члены группы могут отправлять голосовые сообщения.</string>
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этой группе.</string>
</resources>
<string name="onboarding_notifications_mode_off_desc"><b>Минимальный расход батареи</b>. Вы получите уведомления только когда приложение запущено, без фонового сервиса.</string>
<string name="onboarding_notifications_mode_title">Уведомления</string>
<string name="onboarding_notifications_mode_off">Когда приложение запущено</string>
<string name="onboarding_notifications_mode_periodic">Периодически</string>
<string name="onboarding_notifications_mode_service">Мгновенно</string>
<string name="onboarding_notifications_mode_service_desc"><b>Больше расход батареи</b>! Фоновый сервис постоянно запущен - уведомления будут показаны как только есть новые сообщения.</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Меньше расход батареи</b>. Фоновый сервис проверяет новые сообщения каждые 10 минут. Вы можете пропустить звонки и срочные сообщения.</string>
<string name="onboarding_notifications_mode_subtitle">Можно изменить позже в настройках.</string>
<string name="live">LIVE</string>
<string name="send_live_message">Отправить живое сообщение</string>
<string name="live_message">Живое сообщение!</string>
<string name="send_verb">Отправить</string>
<string name="scan_code_from_contacts_app">Сканируйте код безопасности из приложения контакта.</string>
<string name="delete_after">Удалять через</string>
<string name="ttl_sec">%d сек</string>
<string name="ttl_s">%dс</string>
<string name="ttl_min">%d мин</string>
<string name="ttl_month">%d мес.</string>
<string name="ttl_months">%d мес.</string>
<string name="ttl_m">%dм</string>
<string name="ttl_mth">%dмес</string>
<string name="ttl_hour">%d час</string>
<string name="ttl_hours">%d ч.</string>
<string name="ttl_h">%dч</string>
<string name="ttl_day">%d день</string>
<string name="ttl_week">%d нед.</string>
<string name="timed_messages">Исчезающие сообщения</string>
<string name="view_security_code">Показать код безопасности</string>
<string name="verify_security_code">Подтвердить код безопасности</string>
<string name="both_you_and_your_contact_can_send_disappearing">Вы и ваш контакт можете отправлять исчезающие сообщения.</string>
<string name="only_you_can_send_disappearing">Только вы можете отправлять исчезающие сообщения.</string>
<string name="only_your_contact_can_send_disappearing">Только ваш контакт может отправлять исчезающие сообщения.</string>
<string name="disappearing_prohibited_in_this_chat">Исчезающие сообщения запрещены в этом чате.</string>
<string name="allow_to_send_disappearing">Разрешить посылать исчезающие сообщения.</string>
<string name="contact_developers">Пожалуйста, обновите приложение и свяжитесь с разработчиками.</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Разрешить вашим контактам отправлять исчезающие сообщения.</string>
<string name="failed_to_parse_chat_title">Не удалось открыть чат</string>
<string name="failed_to_parse_chats_title">Не удалось открыть чаты</string>
<string name="incorrect_code">Неправильный код безопасности!</string>
<string name="scan_code">Сканировать код</string>
<string name="send_live_message_desc">Отправить живое сообщение — оно будет обновляться для получателей по мере того, как вы его вводите</string>
<string name="create_group_link">Создать ссылку группы</string>
<string name="prohibit_sending_disappearing_messages">Запретить отправлять исчезающие сообщения.</string>
<string name="disappearing_messages_are_prohibited">Исчезающие сообщения запрещены в этой группе.</string>
<string name="ttl_w">%dнед</string>
<string name="ttl_d">%dд</string>
<string name="ttl_weeks">%d нед.</string>
<string name="ttl_days">%d дней</string>
<string name="to_verify_compare">Чтобы подтвердить безопасность end-to-end шифрования с вашим контактом сравните (или сканируйте) код на ваших устройствах.</string>
<string name="is_verified">%s подтверждён</string>
<string name="is_not_verified">%s не подтверждён</string>
<string name="security_code">Код безопасности</string>
<string name="mark_code_verified">Подтвердить</string>
<string name="clear_verification">Сбросить подтверждение</string>
<string name="allow_disappearing_messages_only_if">Разрешить исчезающие сообщения, только если ваш контакт разрешает их вам.</string>
<string name="prohibit_sending_disappearing">Запретить посылать исчезающие сообщения.</string>
<string name="group_members_can_send_disappearing">Члены группы могут посылать исчезающие сообщения.</string>
<string name="whats_new">Новые функции</string>
<string name="new_in_version">Новое в %s</string>
<string name="v4_2_security_assessment">Аудит безопасности</string>
<string name="v4_2_security_assessment_desc">Безопасность SimpleX Chat была проверена Trail of Bits.</string>
<string name="v4_3_voice_messages">Голосовые сообщения</string>
<string name="v4_3_voice_messages_desc">Макс. 40 секунд, доставляются мгновенно.</string>
<string name="v4_3_irreversible_message_deletion">Окончательное удаление сообщений</string>
<string name="v4_3_irreversible_message_deletion_desc">Ваши контакты могут разрешить окончательное удаление сообщений.</string>
<string name="v4_3_improved_server_configuration_desc">Добавить серверы через QR код.</string>
<string name="v4_3_improved_privacy_and_security">Улучшенная безопасность</string>
<string name="v4_3_improved_privacy_and_security_desc">Скрыть экран приложения.</string>
<string name="v4_4_disappearing_messages">Исчезающие сообщения</string>
<string name="v4_4_disappearing_messages_desc">Отправленные сообщения будут удалены через заданное время.</string>
<string name="v4_3_improved_server_configuration">Улучшенная конфигурация серверов</string>
<string name="v4_4_live_messages">\"Живые\" сообщения</string>
<string name="v4_4_live_messages_desc">Получатели видят их в то время как вы их набираете.</string>
<string name="v4_4_verify_connection_security">Проверить безопасность соединения</string>
<string name="v4_4_verify_connection_security_desc">Сравните код безопасности с вашими контактами.</string>
<string name="invalid_chat">ошибка чата</string>
<string name="accept_feature">Принять</string>
<string name="accept_feature_set_1_day">Установить 1 день</string>
<string name="invalid_data">неверные данные</string>
<string name="v4_2_group_links">Ссылки групп</string>
<string name="v4_2_group_links_desc">Админы могут создать ссылки для вступления в группу.</string>
<string name="v4_2_auto_accept_contact_requests">Автоматически принимать запросы контактов</string>
<string name="v4_2_auto_accept_contact_requests_desc">С опциональным авто-ответом.</string>
<string name="feature_offered_item">предложил(a) %s</string>
<string name="feature_offered_item_with_param">предложил(a) %s: %2s</string>
<string name="feature_cancelled_item">отменил(a) %s</string>
</resources>
@@ -27,6 +27,9 @@
<string name="sender_you_pronoun">you</string>
<string name="unknown_message_format">unknown message format</string>
<string name="invalid_message_format">invalid message format</string>
<string name="live">LIVE</string>
<string name="invalid_chat">invalid chat</string>
<string name="invalid_data">invalid data</string>
<!-- PendingContactConnection - ChatModel.kt -->
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
@@ -57,6 +60,9 @@
<string name="error_saving_smp_servers">Error saving SMP servers</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
<string name="error_setting_network_config">Error updating network configuration</string>
<string name="failed_to_parse_chat_title">Failed to load chat</string>
<string name="failed_to_parse_chats_title">Failed to load chats</string>
<string name="contact_developers">Please update the app and contact developers.</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
@@ -250,6 +256,8 @@
<string name="icon_descr_server_status_pending">Pending</string>
<string name="switch_receiving_address_question">Switch receiving address?</string>
<string name="switch_receiving_address_desc">This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member).</string>
<string name="view_security_code">View security code</string>
<string name="verify_security_code">Verify security code</string>
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Send Message</string>
@@ -259,6 +267,11 @@
<string name="voice_messages_prohibited">Voice messages prohibited!</string>
<string name="ask_your_contact_to_enable_voice">Please ask your contact to enable sending voice messages.</string>
<string name="only_group_owners_can_enable_voice">Only group owners can enable voice messages.</string>
<string name="send_live_message">Send live message</string>
<string name="live_message">Live message!</string>
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
<string name="send_verb">Send</string>
<string name="icon_descr_cancel_live_message">Cancel live message</string>
<!-- General Actions / Responses -->
<string name="back">Back</string>
@@ -385,6 +398,19 @@
<string name="one_time_link">One-time invitation link</string>
<string name="your_contact_address">Your contact address</string>
<!-- ScanCodeView.kt -->
<string name="scan_code">Scan code</string>
<string name="incorrect_code">Incorrect security code!</string>
<string name="scan_code_from_contacts_app">Scan security code from your contact\'s app.</string>
<!-- VerifyCodeView.kt -->
<string name="security_code">Security code</string>
<string name="mark_code_verified">Mark verified</string>
<string name="clear_verification">Clear verification</string>
<string name="to_verify_compare">To verify end-to-end encryption with your contact compare (or scan) the code on your devices.</string>
<string name="is_verified">%s is verified</string>
<string name="is_not_verified">%s is not verified</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
@@ -453,6 +479,12 @@
<string name="network_use_onion_hosts_no_desc_in_alert">Onion hosts will not be used.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Onion hosts will be required for connection.</string>
<string name="appearance_settings">Appearance</string>
<string name="app_version_title">App version</string>
<string name="app_version_name">App version: v%s</string>
<string name="app_version_code">App build: %s</string>
<string name="core_version">Core version: v%s</string>
<string name="core_build_timestamp">Core built at: %s</string>
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Create address</string>
@@ -545,6 +577,17 @@
<string name="read_more_in_github">Read more in our GitHub repository.</string>
<string name="read_more_in_github_with_link">Read more in our <font color="#0088ff">GitHub repository</font>.</string>
<!-- SetNotificationsMode.kt -->
<string name="use_chat">Use chat</string>
<string name="onboarding_notifications_mode_title">Private notifications</string>
<string name="onboarding_notifications_mode_subtitle">It can be changed later via settings.</string>
<string name="onboarding_notifications_mode_off">When app is running</string>
<string name="onboarding_notifications_mode_periodic">Periodic</string>
<string name="onboarding_notifications_mode_service">Instant</string>
<string name="onboarding_notifications_mode_off_desc"><b>Best for battery</b>. You will receive notifications only when the app is running, background service will NOT be used.</string>
<string name="onboarding_notifications_mode_periodic_desc"><b>Good for battery</b>. Background service checks for new messages every 10 minutes. You may miss calls and urgent messages.</string>
<string name="onboarding_notifications_mode_service_desc"><b>Uses more battery</b>! Background service is always running notifications will be shown as soon as the messages are available.</string>
<!-- MakeConnection -->
<string name="paste_the_link_you_received">Paste received link</string>
@@ -853,6 +896,7 @@
<string name="button_leave_group">Leave group</string>
<string name="button_edit_group_profile">Edit group profile</string>
<string name="group_link">Group link</string>
<string name="create_group_link">Create group link</string>
<string name="button_create_group_link">Create link</string>
<string name="delete_link_question">Delete link?</string>
<string name="delete_link">Delete link</string>
@@ -914,6 +958,7 @@
<string name="network_option_tcp_connection_timeout">TCP connection timeout</string>
<string name="network_option_protocol_timeout">Protocol timeout</string>
<string name="network_option_ping_interval">PING interval</string>
<string name="network_option_ping_count">PING count</string>
<string name="network_option_enable_tcp_keep_alive">Enable TCP keep-alive</string>
<string name="network_options_revert">Revert</string>
<string name="network_options_save">Save</string>
@@ -957,6 +1002,7 @@
<string name="group_preferences">Group preferences</string>
<string name="set_group_preferences">Set group preferences</string>
<string name="your_preferences">Your preferences</string>
<string name="timed_messages">Disappearing messages</string>
<string name="direct_messages">Direct messages</string>
<string name="full_deletion">Delete for everyone</string>
<string name="voice_messages">Voice messages</string>
@@ -965,12 +1011,21 @@
<string name="feature_enabled_for_contact">enabled for contact</string>
<string name="feature_off">off</string>
<string name="feature_received_prohibited">received, prohibited</string>
<string name="accept_feature">Accept</string>
<string name="accept_feature_set_1_day">Set 1 day</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Allow your contacts to send disappearing messages.</string>
<string name="allow_disappearing_messages_only_if">Allow disappearing messages only if your contact allows them.</string>
<string name="prohibit_sending_disappearing_messages">Prohibit sending disappearing messages.</string>
<string name="allow_your_contacts_irreversibly_delete">Allow your contacts to irreversibly delete sent messages.</string>
<string name="allow_irreversible_message_deletion_only_if">Allow irreversible message deletion only if your contact allows it to you.</string>
<string name="contacts_can_mark_messages_for_deletion">Contacts can mark messages for deletion; you will be able to view them.</string>
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
<string name="both_you_and_your_contact_can_send_disappearing">Both you and your contact can send disappearing messages.</string>
<string name="only_you_can_send_disappearing">Only you can send disappearing messages.</string>
<string name="only_your_contact_can_send_disappearing">Only your contact can send disappearing messages.</string>
<string name="disappearing_prohibited_in_this_chat">Disappearing messages are prohibited in this chat.</string>
<string name="both_you_and_your_contacts_can_delete">Both you and your contact can irreversibly delete sent messages.</string>
<string name="only_you_can_delete_messages">Only you can irreversibly delete messages (your contact can mark them for deletion).</string>
<string name="only_your_contact_can_delete">Only your contact can irreversibly delete messages (you can mark them for deletion).</string>
@@ -979,17 +1034,64 @@
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
<string name="allow_to_send_disappearing">Allow to send disappearing messages.</string>
<string name="prohibit_sending_disappearing">Prohibit sending disappearing messages.</string>
<string name="allow_direct_messages">Allow sending direct messages to members.</string>
<string name="prohibit_direct_messages">Prohibit sending direct messages to members.</string>
<string name="allow_to_delete_messages">Allow to irreversibly delete sent messages.</string>
<string name="prohibit_message_deletion">Prohibit irreversible message deletion.</string>
<string name="allow_to_send_voice">Allow to send voice messages.</string>
<string name="prohibit_sending_voice">Prohibit sending voice messages.</string>
<string name="group_members_can_send_disappearing">Group members can send disappearing messages.</string>
<string name="disappearing_messages_are_prohibited">Disappearing messages are prohibited in this group.</string>
<string name="group_members_can_send_dms">Group members can send direct messages.</string>
<string name="direct_messages_are_prohibited_in_chat">Direct messages between members are prohibited in this group.</string>
<string name="group_members_can_delete">Group members can irreversibly delete sent messages.</string>
<string name="message_deletion_prohibited_in_chat">Irreversible message deletion is prohibited in this group.</string>
<string name="group_members_can_send_voice">Group members can send voice messages.</string>
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this group.</string>
<string name="delete_after">Delete after</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_s">%ds</string>
<string name="ttl_min">%d min</string>
<string name="ttl_month">%d month</string>
<string name="ttl_months">%d months</string>
<string name="ttl_m">%dm</string>
<string name="ttl_mth">%dmth</string>
<string name="ttl_hour">%d hour</string>
<string name="ttl_hours">%d hours</string>
<string name="ttl_h">%dh</string>
<string name="ttl_day">%d day</string>
<string name="ttl_days">%d days</string>
<string name="ttl_d">%dd</string>
<string name="ttl_week">%d week</string>
<string name="ttl_weeks">%d weeks</string>
<string name="ttl_w">%dw</string>
<string name="feature_offered_item">offered %s</string>
<string name="feature_offered_item_with_param">offered %s: %2s</string>
<string name="feature_cancelled_item">cancelled %s</string>
<!-- WhatsNewView.kt -->
<string name="whats_new">What\'s new</string>
<string name="new_in_version">New in %s</string>
<string name="v4_2_security_assessment">Security assessment</string>
<string name="v4_2_security_assessment_desc">SimpleX Chat security was audited by Trail of Bits.</string>
<string name="v4_2_group_links">Group links</string>
<string name="v4_2_group_links_desc">Admins can create the links to join groups.</string>
<string name="v4_2_auto_accept_contact_requests">Auto-accept contact requests</string>
<string name="v4_2_auto_accept_contact_requests_desc">With optional welcome message.</string>
<string name="v4_3_voice_messages">Voice messages</string>
<string name="v4_3_voice_messages_desc">Max 40 seconds, received instantly.</string>
<string name="v4_3_irreversible_message_deletion">Irreversible message deletion</string>
<string name="v4_3_irreversible_message_deletion_desc">Your contacts can allow full message deletion.</string>
<string name="v4_3_improved_server_configuration">Improved server configuration</string>
<string name="v4_3_improved_server_configuration_desc">Add servers by scanning QR codes.</string>
<string name="v4_3_improved_privacy_and_security">Improved privacy and security</string>
<string name="v4_3_improved_privacy_and_security_desc">Hide app screen in the recent apps.</string>
<string name="v4_4_disappearing_messages">Disappearing messages</string>
<string name="v4_4_disappearing_messages_desc">Sent messages will be deleted after set time.</string>
<string name="v4_4_live_messages">Live messages</string>
<string name="v4_4_live_messages_desc">Recipients see updates as you type them.</string>
<string name="v4_4_verify_connection_security">Verify connection security</string>
<string name="v4_4_verify_connection_security_desc">Compare security codes with your contacts.</string>
</resources>
+10 -2
View File
@@ -17,8 +17,9 @@ struct ContentView: View {
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
@State private var showWhatsNew = false
var body: some View {
ZStack {
@@ -58,12 +59,19 @@ struct ContentView: View {
onAuthorized: { notificationAlertShown = false }
)
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice) {
if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
showWhatsNew = shouldShowWhatsNew()
}
}
prefShowLANotice = true
}
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
}
if chatModel.showCallView, let call = chatModel.activeCall {
ActiveCallView(call: call)
}
@@ -1,4 +1,4 @@
import UIKit
//import UIKit
let s = """
{
@@ -15,6 +15,6 @@ let s = """
}
"""
//let s = "\"2022-04-24T11:59:23.703162Z\""
let json = getJSONDecoder()
let d = s.data(using: .utf8)!
print (try! json.decode(ChatInfo.self, from: d))
//let json = getJSONDecoder()
//let d = s.data(using: .utf8)!
//print (try! json.decode(ChatInfo.self, from: d))
+50 -10
View File
@@ -101,7 +101,7 @@ final class ChatModel: ObservableObject {
}
func updateContact(_ contact: Contact) {
updateChat(.direct(contact: contact), addMissing: contact.directContact)
updateChat(.direct(contact: contact), addMissing: contact.directOrUsed)
}
func updateGroup(_ groupInfo: GroupInfo) {
@@ -219,7 +219,7 @@ final class ChatModel: ObservableObject {
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
let ci = reversedChatItems[i]
withAnimation(.default) {
withAnimation {
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
// on some occasions the confirmation of message being accepted by the server (tick)
@@ -230,9 +230,18 @@ final class ChatModel: ObservableObject {
}
return false
} else {
withAnimation { reversedChatItems.insert(cItem, at: 0) }
withAnimation(itemAnimation()) {
reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
}
return true
}
func itemAnimation() -> Animation? {
switch cItem.chatDir {
case .directSnd, .groupSnd: return cItem.meta.isLive ? nil : .default
default: return .default
}
}
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
@@ -274,6 +283,28 @@ final class ChatModel: ObservableObject {
return nil
}
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
reversedChatItems.insert(cItem, at: 0)
}
return cItem
}
func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy {
if animated {
withAnimation { _ = reversedChatItems.removeFirst() }
} else {
_ = reversedChatItems.removeFirst()
}
}
}
private var hasLiveDummy: Bool {
reversedChatItems.first?.isLiveDummy == true
}
func markChatItemsRead(_ cInfo: ChatInfo) {
// update preview
_updateChat(cInfo.id) { chat in
@@ -289,10 +320,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < reversedChatItems.count {
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
}
markChatItemRead_(j)
j += 1
}
}
@@ -347,9 +375,19 @@ final class ChatModel: ObservableObject {
// update preview
decreaseUnreadCounter(cInfo)
// update current chat
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
markChatItemRead_(i)
}
}
private func markChatItemRead_(_ i: Int) {
let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
reversedChatItems[i].meta.itemStatus = .rcvRead
reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
@@ -513,4 +551,6 @@ final class Chat: ObservableObject, Identifiable {
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
+205
View File
@@ -0,0 +1,205 @@
//
// ImageUtils.swift
// SimpleX (iOS)
//
// Created by Evgeny on 24/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
import SwiftUI
func getLoadedFilePath(_ file: CIFile?) -> String? {
if let fileName = getLoadedFileName(file) {
return getAppFilePath(fileName).path
}
return nil
}
func getLoadedFileName(_ file: CIFile?) -> String? {
if let file = file,
file.loaded,
let fileName = file.filePath {
return fileName
}
return nil
}
func getLoadedImage(_ file: CIFile?) -> UIImage? {
let loadedFilePath = getLoadedFilePath(file)
if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
let filePath = getAppFilePath(fileName)
do {
let data = try Data(contentsOf: filePath)
let img = UIImage(data: data)
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
return img
} catch {
return UIImage(contentsOfFile: loadedFilePath)
}
}
return nil
}
func saveAnimImage(_ image: UIImage) -> String? {
let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName)
}
func saveImage(_ uiImage: UIImage) -> String? {
let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
let fileName = generateNewFileName("IMG", ext)
return saveFile(imageDataResized, fileName)
}
return nil
}
func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size
let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side)
var origin = CGPoint.zero
if size.width > side {
origin.x -= (size.width - side) / 2
} else if size.height > side {
origin.y -= (size.height - side) / 2
}
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
}
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
var img = image
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha)
data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
dataSize = data?.count ?? 0
}
logger.debug("resizeImageToDataSize final \(dataSize)")
return data
}
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image
let hasAlpha = imageHasAlpha(image)
var str = compressImageStr(img, hasAlpha: hasAlpha)
var dataSize = str?.count ?? 0
while dataSize != 0 && dataSize > maxDataSize {
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
let clippedRatio = min(ratio, 2.0)
img = reduceSize(img, ratio: clippedRatio, hasAlpha: hasAlpha)
str = compressImageStr(img, hasAlpha: hasAlpha)
dataSize = str?.count ?? 0
}
logger.debug("resizeImageToStrSize final \(dataSize)")
return str
}
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
let ext = hasAlpha ? "png" : "jpg"
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())"
}
return nil
}
private func reduceSize(_ image: UIImage, ratio: CGFloat, hasAlpha: Bool) -> UIImage {
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
let bounds = CGRect(origin: .zero, size: newSize)
return resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha)
}
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
format.opaque = !hasAlpha
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
image.draw(in: drawIn)
}
}
func imageHasAlpha(_ img: UIImage) -> Bool {
if let cgImage = img.cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) {
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if let data = context.data {
let data = data.assumingMemoryBound(to: UInt8.self)
let size = cgImage.width * cgImage.height
var i = 0
while i < size {
if data[i] < 255 { return true }
i += 4
}
}
}
}
return false
}
func saveFileFromURL(_ url: URL) -> String? {
let savedFile: String?
if url.startAccessingSecurityScopedResource() {
do {
let fileData = try Data(contentsOf: url)
let fileName = uniqueCombine(url.lastPathComponent)
savedFile = saveFile(fileData, fileName)
} catch {
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
savedFile = nil
}
} else {
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
savedFile = nil
}
url.stopAccessingSecurityScopedResource()
return savedFile
}
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
let fileName = uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
return fileName
}
private func uniqueCombine(_ fileName: String) -> String {
func tryCombine(_ fileName: String, _ n: Int) -> String {
let ns = fileName as NSString
let name = ns.deletingPathExtension
let ext = ns.pathExtension
let suffix = (n == 0) ? "" : "_\(n)"
let f = "\(name)\(suffix).\(ext)"
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
}
return tryCombine(fileName, 0)
}
private var tsFormatter: DateFormatter?
private func getTimestamp() -> String {
var df: DateFormatter
if let tsFormatter = tsFormatter {
df = tsFormatter
} else {
df = DateFormatter()
df.dateFormat = "yyyyMMdd_HHmmss"
df.locale = Locale(identifier: "US")
tsFormatter = df
}
return df.string(from: Date())
}
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
+48 -10
View File
@@ -219,9 +219,9 @@ func loadChat(chat: Chat, search: String = "") {
}
}
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async -> ChatItem? {
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
let chatModel = ChatModel.shared
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
let r: ChatResponse
if type == .direct {
var cItem: ChatItem!
@@ -255,8 +255,8 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
)
}
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay)
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay)
if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
throw r
}
@@ -356,14 +356,14 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
throw r
}
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) {
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) {
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) }
throw r
}
@@ -376,6 +376,32 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws
try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
}
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
let r = await chatSendCmd(.apiGetContactCode(contactId: contactId))
if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) }
throw r
}
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) }
throw r
}
func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? {
let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
logger.error("apiVerifyContact error: \(String(describing: r))")
return nil
}
func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? {
let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
logger.error("apiVerifyGroupMember error: \(String(describing: r))")
return nil
}
func apiAddContact() async -> String? {
let r = await chatSendCmd(.addContact, bgTask: false)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
@@ -782,6 +808,12 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
return []
}
func apiListMembersSync(_ groupId: Int64) -> [GroupMember] {
let r = chatSendCmdSync(.apiListMembers(groupId: groupId))
if case let .groupMembers(group) = r { return group.members }
return []
}
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
return ChatModel.shared.chats
@@ -819,6 +851,12 @@ func apiGetGroupLink(_ groupId: Int64) throws -> String? {
}
}
func apiGetVersion() throws -> CoreVersionInfo {
let r = chatSendCmdSync(.showVersion)
if case let .versionInfo(info) = r { return info }
throw r
}
func initializeChat(start: Bool, dbKey: String? = nil) throws {
logger.debug("initializeChat")
let m = ChatModel.shared
@@ -917,7 +955,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .contactConnectionDeleted(connection):
m.removeChat(connection.id)
case let .contactConnected(contact, _):
if contact.directContact {
if contact.directOrUsed {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
@@ -925,7 +963,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(contact)
}
case let .contactConnecting(contact):
if contact.directContact {
if contact.directOrUsed {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
@@ -974,7 +1012,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.addChatItem(cInfo, cItem)
if let file = cItem.file,
let mc = cItem.content.msgContent,
file.fileSize <= MAX_IMAGE_SIZE {
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV {
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
if (mc.isImage && acceptImages)
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
@@ -32,15 +32,26 @@ struct ChatInfoToolbar: View {
.frame(width: imageSize, height: imageSize)
.padding(.trailing, 4)
VStack {
Text(cInfo.displayName).font(.headline)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
}
}
}
.foregroundColor(.primary)
.frame(width: 220)
}
private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
}
struct ChatInfoToolbar_Previews: PreviewProvider {
+65 -15
View File
@@ -9,7 +9,7 @@
import SwiftUI
import SimpleXChat
func infoRow<S>(_ title: S, _ value: String) -> some View where S: StringProtocol {
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(title)
Spacer()
@@ -18,6 +18,15 @@ func infoRow<S>(_ title: S, _ value: String) -> some View where S: StringProtoco
}
}
func infoRow(_ title: Text, _ value: String) -> some View {
HStack {
title
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey) -> some View {
HStack {
Text(title)
@@ -55,8 +64,9 @@ struct ChatInfoView: View {
@ObservedObject var chat: Chat
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
var customUserProfile: Profile?
@Binding var customUserProfile: Profile?
@State var localAlias: String
@Binding var connectionCode: String?
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -89,7 +99,9 @@ struct ChatInfoView: View {
aliasTextFieldFocused = false
}
localAliasTextEdit()
Group {
localAliasTextEdit()
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -100,6 +112,7 @@ struct ChatInfoView: View {
}
Section {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
}
@@ -143,17 +156,23 @@ struct ChatInfoView: View {
}
}
func contactInfoHeader() -> some View {
private func contactInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(contact.profile.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
HStack {
if contact.verified {
Image(systemName: "checkmark.shield")
.foregroundColor(.secondary)
}
Text(contact.profile.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
}
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
Text(cInfo.fullName)
.font(.title2)
@@ -163,7 +182,7 @@ struct ChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func localAliasTextEdit() -> some View {
private func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
.focused($aliasTextFieldFocused)
@@ -194,7 +213,36 @@ struct ChatInfoView: View {
}
}
func contactPreferencesButton() -> some View {
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
displayName: contact.displayName,
connectionCode: code,
connectionVerified: contact.verified,
verify: { code in
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
let (verified, existingCode) = r
contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode
DispatchQueue.main.async {
chat.chatInfo = .direct(contact: contact)
}
return r
}
return nil
}
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
} label: {
Label(
contact.verified ? "View security code" : "Verify security code",
systemImage: contact.verified ? "checkmark.shield" : "shield"
)
}
}
private func contactPreferencesButton() -> some View {
NavigationLink {
ContactPreferencesView(
contact: $contact,
@@ -208,7 +256,7 @@ struct ChatInfoView: View {
}
}
func networkStatusRow() -> some View {
private func networkStatusRow() -> some View {
HStack {
Text("Network status")
Image(systemName: "info.circle")
@@ -221,14 +269,14 @@ struct ChatInfoView: View {
}
}
func serverImage() -> some View {
private func serverImage() -> some View {
let status = chat.serverInfo.networkStatus
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : .secondary)
.font(.system(size: 12))
}
func deleteContactButton() -> some View {
private func deleteContactButton() -> some View {
Button(role: .destructive) {
alert = .deleteContactAlert
} label: {
@@ -237,7 +285,7 @@ struct ChatInfoView: View {
}
}
func clearChatButton() -> some View {
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
} label: {
@@ -323,7 +371,9 @@ struct ChatInfoView_Previews: PreviewProvider {
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
localAlias: ""
customUserProfile: Binding.constant(nil),
localAlias: "",
connectionCode: Binding.constant(nil)
)
}
}
@@ -0,0 +1,63 @@
//
// Created by Avently on 19.12.2022.
// Copyright (c) 2022 SimpleX Chat. All rights reserved.
//
import UIKit
import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
convenience init(image: UIImage) {
self.init()
self.image = image
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
}
override func layoutSubviews() {
super.layoutSubviews()
imageView!.frame = bounds
}
func updateImage(_ image: UIImage) {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
subview.removeFromSuperview()
}
}
imageView!.frame = bounds
self.layoutSubviews()
}
}
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
init(image: UIImage) {
self.image = image
}
func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
imageView.updateImage(image)
imageView.imageView!.startAnimatingGif()
}
}
@@ -12,12 +12,14 @@ import SimpleXChat
struct CIChatFeatureView: View {
var chatItem: ChatItem
var feature: Feature
var icon: String? = nil
var iconColor: Color
var body: some View {
HStack(alignment: .bottom, spacing: 4) {
Image(systemName: feature.iconFilled)
Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor)
.scaleEffect(feature.iconScale)
chatEventText(chatItem)
}
.padding(.leading, 6)
@@ -0,0 +1,86 @@
//
// CIFeaturePreferenceView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 21/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CIFeaturePreferenceView: View {
@EnvironmentObject var chat: Chat
var chatItem: ChatItem
var feature: ChatFeature
var allowed: FeatureAllowed
var param: Int?
var body: some View {
HStack(alignment: .center, spacing: 4) {
Image(systemName: feature.icon)
.foregroundColor(.secondary)
.scaleEffect(feature.iconScale)
if let ct = chat.chatInfo.contact,
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
.onTapGesture {
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
}
} else {
featurePreferenceView()
}
}
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled)
}
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
.fontWeight(.light)
.foregroundColor(.secondary)
if let acceptText {
r = r
+ Text(acceptText)
.fontWeight(.medium)
.foregroundColor(.accentColor)
+ Text(" ")
}
r = r + chatItem.timestampText
.fontWeight(.light)
.foregroundColor(.secondary)
return r.font(.caption)
}
}
func allowFeatureToContact(_ contact: Contact, _ feature: ChatFeature, param: Int? = nil) {
Task {
do {
let prefs = contactUserPreferencesToPreferences(contact.mergedPreferences).setAllowed(feature, param: param)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
ChatModel.shared.updateContact(toContact)
}
}
} catch {
logger.error("allowFeatureToContact apiSetContactPrefs error: \(responseError(error))")
}
}
}
struct CIFeaturePreferenceView_Previews: PreviewProvider {
static var previews: some View {
let content = CIContent.rcvChatPreference(feature: .timedMessages, allowed: .yes, param: 30)
let chatItem = ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false),
content: content,
quotedItem: nil,
file: nil
)
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
.environmentObject(Chat.sampleData)
}
}
@@ -161,5 +161,6 @@ struct CIFileView_Previews: PreviewProvider {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}
@@ -55,13 +55,19 @@ struct CIImageView: View {
}
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : img.imageData == nil ? .infinity : maxWidth
DispatchQueue.main.async { imgWidth = w }
return ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: w)
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: w)
} else {
SwiftyGif(image: img)
.frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit()
}
loadingIndicator()
}
}
@@ -0,0 +1,53 @@
//
// CIInvalidJSONView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 29.12.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct CIInvalidJSONView: View {
var json: String
@State private var showJSON = false
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
Text("invalid data")
.foregroundColor(.red)
.italic()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.onTapGesture { showJSON = true }
.sheet(isPresented: $showJSON) {
invalidJSONView(json)
}
}
}
func invalidJSONView(_ json: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
Button {
showShareSheet(items: [json])
} label: {
Image(systemName: "square.and.arrow.up")
}
.frame(maxWidth: .infinity, alignment: .trailing)
ScrollView {
Text(json)
}
}
.frame(maxHeight: .infinity)
.padding()
}
struct CIInvalidJSONView_Previews: PreviewProvider {
static var previews: some View {
CIInvalidJSONView(json: "{}")
}
}
@@ -10,42 +10,42 @@ import SwiftUI
import SimpleXChat
struct CIMetaView: View {
@EnvironmentObject var chat: Chat
var chatItem: ChatItem
var metaColor = Color.secondary
var body: some View {
HStack(alignment: .center, spacing: 4) {
if !chatItem.isDeletedContent {
if chatItem.meta.itemEdited {
statusImage("pencil", metaColor, 9)
}
switch chatItem.meta.itemStatus {
case .sndSent:
statusImage("checkmark", metaColor)
case .sndErrorAuth:
statusImage("multiply", .red)
case .sndError:
statusImage("exclamationmark.triangle.fill", .yellow)
case .rcvNew:
statusImage("circlebadge.fill", Color.accentColor)
default: EmptyView()
}
}
chatItem.timestampText
.font(.caption)
.foregroundColor(metaColor)
if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else {
ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor)
}
}
}
private func statusImage(_ systemName: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View {
Image(systemName: systemName)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(color)
.frame(maxHeight: maxHeight)
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false) -> Text {
var r = Text("")
if meta.itemEdited {
r = r + statusIconText("pencil", color)
}
if meta.disappearing {
r = r + statusIconText("timer", color).font(.caption2)
let ttl = meta.itemTimed?.ttl
if ttl != chatTTL {
r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color)
}
r = r + Text(" ")
}
if let (icon, statusColor) = meta.statusIcon(color) {
r = r + statusIconText(icon, transparent ? .clear : statusColor) + Text(" ")
} else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
}
return (r + meta.timestampText.foregroundColor(color)).font(.caption)
}
private func statusIconText(_ icon: String, _ color: Color) -> Text {
Text(Image(systemName: icon)).foregroundColor(color)
}
struct CIMetaView_Previews: PreviewProvider {
@@ -56,5 +56,6 @@ struct CIMetaView_Previews: PreviewProvider {
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 100))
.environmentObject(Chat.sampleData)
}
}
@@ -231,16 +231,12 @@ struct CIVoiceView_Previews: PreviewProvider {
playbackState: .playing,
playbackTime: TimeInterval(20)
)
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
.environmentObject(ChatModel())
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}
@@ -61,16 +61,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
.environmentObject(ChatModel())
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}
@@ -15,7 +15,6 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1,
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
struct FramedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
var chatInfo: ChatInfo
var chatItem: ChatItem
@@ -31,14 +30,16 @@ struct FramedItemView: View {
let v = ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 0) {
if chatItem.meta.itemDeleted {
ciDeletedView()
framedItemHeader(icon: "trash", caption: Text("marked deleted").italic())
} else if chatItem.meta.isLive {
framedItemHeader(caption: Text("LIVE"))
}
if let qi = chatItem.quotedItem {
ciQuoteView(qi)
.onTapGesture {
if let proxy = scrollProxy,
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
let ci = ChatModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation {
proxy.scrollTo(ci.viewId, anchor: .bottom)
}
@@ -73,7 +74,7 @@ struct FramedItemView: View {
}
@ViewBuilder private func framedMsgContentView() -> some View {
if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) {
if chatItem.formattedText == nil && chatItem.file == nil && !chatItem.meta.isLive && isShortEmoji(chatItem.content.text) {
VStack {
emojiText(chatItem.content.text)
Text("")
@@ -88,7 +89,7 @@ struct FramedItemView: View {
case let .image(text, image):
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
.overlay(DetermineWidth())
if text == "" {
if text == "" && !chatItem.meta.isLive {
Color.clear
.frame(width: 0, height: 0)
.preference(
@@ -127,32 +128,33 @@ struct FramedItemView: View {
message: err
)
}
@ViewBuilder private func ciDeletedView() -> some View {
@ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text) -> some View {
let v = HStack(spacing: 6) {
Image(systemName: "trash")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
Text("marked deleted")
if let icon = icon {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
}
caption
.font(.caption)
.italic()
.lineLimit(1)
}
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.padding(.top, 6)
.padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup
.overlay(DetermineWidth())
.frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, colorScheme))
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.padding(.top, 6)
.padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup
.overlay(DetermineWidth())
.frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, colorScheme))
if let imgWidth = imgWidth, imgWidth < maxWidth {
v.frame(maxWidth: imgWidth, alignment: .leading)
} else {
v
}
}
@ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View {
let v = ZStack(alignment: .topTrailing) {
switch (qi.content) {
@@ -222,21 +224,21 @@ struct FramedItemView: View {
}
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View {
let rtl = isRightToLeft(chatItem.text)
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text)
let v = MsgContentView(
text: ci.text,
formattedText: ci.formattedText,
text: text,
formattedText: text == "" ? [] : ci.formattedText,
sender: showMember ? ci.memberDisplayName : nil,
metaText: ci.timestampText,
edited: ci.meta.itemEdited,
meta: ci.meta,
rightToLeft: rtl
)
.multilineTextAlignment(rtl ? .trailing : .leading)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.overlay(DetermineWidth())
.frame(minWidth: 0, alignment: .leading)
.textSelection(.enabled)
.multilineTextAlignment(rtl ? .trailing : .leading)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.overlay(DetermineWidth())
.frame(minWidth: 0, alignment: .leading)
.textSelection(.enabled)
if let imgWidth = imgWidth, imgWidth < maxWidth {
v.frame(maxWidth: imgWidth, alignment: .leading)
@@ -248,7 +250,7 @@ struct FramedItemView: View {
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
.overlay(DetermineWidth())
if text != "" {
if text != "" || ci.meta.isLive {
ciMsgContentView (chatItem, showMember)
}
}
@@ -270,7 +272,7 @@ private struct MetaColorPreferenceKey: PreferenceKey {
func onlyImage(_ ci: ChatItem) -> Bool {
if case let .image(text, _) = ci.content.msgContent {
return !ci.meta.itemDeleted && ci.quotedItem == nil && text == ""
return !ci.meta.itemDeleted && !ci.meta.isLive && ci.quotedItem == nil && text == ""
}
return false
}
@@ -8,6 +8,7 @@
import SwiftUI
import SimpleXChat
import SwiftyGif
struct FullScreenImageView: View {
@EnvironmentObject var m: ChatModel
@@ -77,9 +78,14 @@ struct FullScreenImageView: View {
private func imageView(_ img: UIImage) -> some View {
ZStack {
Color.black
Image(uiImage: img)
.resizable()
.scaledToFit()
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.scaledToFit()
} else {
SwiftyGif(image: img)
.scaledToFit()
}
}
}
@@ -11,28 +11,76 @@ import SimpleXChat
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let noTyping = Text(" ")
private let typingIndicators: [Text] = [
(typing(.black) + typing() + typing()),
(typing(.bold) + typing(.black) + typing()),
(typing() + typing(.bold) + typing(.black)),
(typing() + typing() + typing(.bold))
]
private func typing(_ w: Font.Weight = .light) -> Text {
Text(".").fontWeight(w)
}
struct MsgContentView: View {
@EnvironmentObject var chat: Chat
var text: String
var formattedText: [FormattedText]? = nil
var sender: String? = nil
var metaText: Text? = nil
var edited = false
var meta: CIMeta? = nil
var rightToLeft = false
@State private var typingIdx = 0
@State private var timer: Timer?
var body: some View {
let v = messageText(text, formattedText, sender)
if let mt = metaText {
return v + reserveSpaceForMeta(mt, edited)
if meta?.isLive == true {
msgContentView()
.onAppear { switchTyping() }
.onDisappear(perform: stopTyping)
.onChange(of: meta?.isLive, perform: switchTyping)
.onChange(of: meta?.recent, perform: switchTyping)
} else {
return v
msgContentView()
}
}
private func reserveSpaceForMeta(_ meta: Text, _ edited: Bool) -> Text {
let reserve = rightToLeft ? "\n" : edited ? " " : " "
return (Text(reserve) + meta)
.font(.caption)
.foregroundColor(.clear)
private func switchTyping(_: Bool? = nil) {
if let meta = meta, meta.isLive && meta.recent {
timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
typingIdx = (typingIdx + 1) % typingIndicators.count
}
} else {
stopTyping()
}
}
private func stopTyping() {
timer?.invalidate()
timer = nil
}
private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender)
if let mt = meta {
if mt.isLive {
v = v + typingIndicator(mt.recent)
}
v = v + reserveSpaceForMeta(mt)
}
return v
}
private func typingIndicator(_ recent: Bool) -> Text {
return (recent ? typingIndicators[typingIdx] : noTyping)
.font(.body.monospaced())
.kerning(-2)
.foregroundColor(.secondary)
}
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, transparent: true)
}
}
@@ -105,7 +153,8 @@ struct MsgContentView_Previews: PreviewProvider {
text: chatItem.text,
formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName,
metaText: chatItem.timestampText
meta: chatItem.meta
)
.environmentObject(Chat.sampleData)
}
}
+15 -7
View File
@@ -21,7 +21,7 @@ struct ChatItemView: View {
let ci = chatItem
if chatItem.meta.itemDeleted && !revealed {
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
} else if ci.quotedItem == nil && !ci.meta.itemDeleted {
} else if ci.quotedItem == nil && !ci.meta.itemDeleted && !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 {
@@ -62,12 +62,17 @@ struct ChatItemContentView<Content: View>: View {
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView()
case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary)
case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
case let .invalidJSON(json): CIInvalidJSONView(json: json)
}
}
@@ -102,15 +107,17 @@ struct ChatItemView_Previews: PreviewProvider {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(true))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, false, false, true), revealed: Binding.constant(true))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, false, false, true), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
}
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
static var previews: some View {
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false))
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
@@ -158,5 +165,6 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
)
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
}
+50 -39
View File
@@ -8,13 +8,15 @@
import SwiftUI
import SimpleXChat
import SwiftyGif
private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
@Environment(\.dismiss) var dismiss
@State @ObservedObject var chat: Chat
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@@ -23,6 +25,7 @@ struct ChatView: View {
@State private var showDeleteMessage = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
@State private var tableView: UITableView?
@State private var loadingItems = false
@State private var firstPage = false
@@ -33,8 +36,7 @@ struct ChatView: View {
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GroupMember? = nil
@State private var memberConnectionStats: ConnectionStats?
var body: some View {
let cInfo = chat.chatInfo
return VStack(spacing: 0) {
@@ -69,6 +71,9 @@ struct ChatView: View {
}
}
}
.onChange(of: chatModel.chatId) { _ in
if chatModel.chatId == nil { dismiss() }
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
@@ -90,24 +95,30 @@ struct ChatView: View {
Button {
Task {
do {
let (stats, profile) = try await apiContactInfo(contactId: chat.chatInfo.apiId)
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn.connectionCode != ct.activeConn.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo error: \(responseError(error))")
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
await MainActor.run { showChatInfoSheet = true }
}
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
connectionCode = nil
}) {
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: customUserProfile, localAlias: chat.chatInfo.localAlias)
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
}
} else if case let .group(groupInfo) = cInfo {
Button {
@@ -246,9 +257,10 @@ struct ChatView: View {
loadChat(chat: chat, search: searchText)
}
.onChange(of: chatModel.chatId) { _ in
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
if let chatId = chatModel.chatId, let c = chatModel.getChat(chatId) {
chat = c
showChatInfoSheet = false
loadChat(chat: chat)
loadChat(chat: c)
DispatchQueue.main.async {
scrollToBottom(proxy)
}
@@ -381,29 +393,15 @@ struct ChatView: View {
if showMember {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture {
Task {
do {
let stats = try await apiGroupMemberInfo(member.groupId, member.groupMemberId)
await MainActor.run { memberConnectionStats = stats }
} catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))")
}
await MainActor.run { selectedMember = member }
}
}
.appSheet(item: $selectedMember, onDismiss: {
selectedMember = nil
memberConnectionStats = nil
}) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $memberConnectionStats)
.onTapGesture { selectedMember = member }
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
}
} else {
Rectangle().fill(.clear)
.frame(width: memberImageSize, height: memberImageSize)
}
ChatItemWithMenu(
chat: chat,
ci: ci,
showMember: showMember,
maxWidth: maxWidth,
@@ -412,13 +410,14 @@ struct ChatView: View {
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
).padding(.leading, 8)
)
.padding(.leading, 8)
.environmentObject(chat)
}
.padding(.trailing)
.padding(.leading, 12)
} else {
ChatItemWithMenu(
chat: chat,
ci: ci,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
@@ -426,12 +425,14 @@ struct ChatView: View {
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
).padding(.horizontal)
)
.padding(.horizontal)
.environmentObject(chat)
}
}
private struct ChatItemWithMenu: View {
var chat: Chat
@EnvironmentObject var chat: Chat
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat
@@ -445,9 +446,13 @@ struct ChatView: View {
var body: some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
let uiMenu: Binding<UIMenu> = Binding(
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
set: { _ in }
)
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
.uiKitContextMenu(actions: menu())
.uiKitContextMenu(menu: uiMenu)
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
@@ -462,28 +467,34 @@ struct ChatView: View {
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
}
private func menu() -> [UIAction] {
private func menu(live: Bool) -> [UIAction] {
var menu: [UIAction] = []
if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed {
if !ci.meta.itemDeleted {
if !ci.meta.itemDeleted && !ci.isLiveDummy && !live {
menu.append(replyUIAction())
}
menu.append(shareUIAction())
menu.append(copyUIAction())
if let filePath = getLoadedFilePath(ci.file) {
if case .image = ci.content.msgContent, let image = UIImage(contentsOfFile: filePath) {
menu.append(saveImageAction(image))
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil {
menu.append(saveFileAction(filePath))
} else {
menu.append(saveImageAction(image))
}
} else {
menu.append(saveFileAction(filePath))
}
}
if ci.meta.editable && !mc.isVoice {
if ci.meta.editable && !mc.isVoice && !live {
menu.append(editAction())
}
if revealed {
menu.append(hideUIAction())
}
menu.append(deleteUIAction())
if !live || !ci.meta.isLive {
menu.append(deleteUIAction())
}
} else if ci.meta.itemDeleted {
menu.append(revealUIAction())
menu.append(deleteUIAction())
@@ -600,7 +611,7 @@ struct ChatView: View {
}
private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.fullDeletionAllowed ? "Delete for everyone" : "Mark deleted for everyone"
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
}
@@ -8,6 +8,8 @@
import SwiftUI
import SimpleXChat
import SwiftyGif
import PhotosUI
enum ComposePreview {
case noPreview
@@ -29,8 +31,15 @@ enum VoiceMessageRecordingState {
case finished
}
struct LiveMessage {
var chatItem: ChatItem
var typedMsg: String
var sentMsg: String?
}
struct ComposeState {
var message: String
var liveMessage: LiveMessage? = nil
var preview: ComposePreview
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
@@ -40,11 +49,13 @@ struct ComposeState {
init(
message: String = "",
liveMessage: LiveMessage? = nil,
preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem,
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
) {
self.message = message
self.liveMessage = liveMessage
self.preview = preview
self.contextItem = contextItem
self.voiceMessageRecordingState = voiceMessageRecordingState
@@ -64,12 +75,14 @@ struct ComposeState {
func copy(
message: String? = nil,
liveMessage: LiveMessage? = nil,
preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil,
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
) -> ComposeState {
ComposeState(
message: message ?? self.message,
liveMessage: liveMessage ?? self.liveMessage,
preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem,
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
@@ -83,15 +96,26 @@ struct ComposeState {
}
}
var quoting: Bool {
switch contextItem {
case .quotedItem: return true
default: return false
}
}
var sendEnabled: Bool {
switch preview {
case .imagePreviews: return true
case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true
default: return !message.isEmpty
default: return !message.isEmpty || liveMessage != nil
}
}
var endLiveDisabled: Bool {
liveMessage != nil && message.isEmpty && noPreview && !quoting
}
var linkPreviewAllowed: Bool {
switch preview {
case .imagePreviews: return false
@@ -128,6 +152,15 @@ struct ComposeState {
default: return false
}
}
var attachmentDisabled: Bool {
if editing || liveMessage != nil { return true }
switch preview {
case .noPreview: return false
case .linkPreview: return false
default: return true
}
}
}
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
@@ -149,6 +182,37 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
return chatItemPreview
}
enum UploadContent: Equatable {
case simpleImage(image: UIImage)
case animatedImage(image: UIImage)
var uiImage: UIImage {
switch self {
case let .simpleImage(image): return image
case let .animatedImage(image): return image
}
}
static func loadFromURL(url: URL) -> UploadContent? {
do {
let data = try Data(contentsOf: url)
if let image = UIImage(data: data) {
try image.setGifFromData(data, levelOfIntegrity: 1.0)
logger.log("UploadContent: added animated image")
return .animatedImage(image: image)
} else { return nil }
} catch {
do {
if let image = try UIImage(data: Data(contentsOf: url)) {
logger.log("UploadContent: added simple image")
return .simpleImage(image: image)
}
} catch {}
}
return nil
}
}
struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat
@@ -163,7 +227,7 @@ struct ComposeView: View {
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showTakePhoto = false
@State var chosenImages: [UIImage] = []
@State var chosenImages: [UploadContent] = []
@State private var showFileImporter = false
@State var chosenFile: URL? = nil
@@ -174,14 +238,14 @@ struct ComposeView: View {
// fails to stop on ComposeVoiceView.playbackMode().onDisappear,
// this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false
var body: some View {
VStack(spacing: 0) {
contextItemView()
switch (composeState.editing, composeState.preview) {
case (true, .filePreview): EmptyView()
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
case (true, .filePreview): EmptyView()
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
}
HStack (alignment: .bottom) {
Button {
@@ -190,7 +254,7 @@ struct ComposeView: View {
Image(systemName: "paperclip")
.resizable()
}
.disabled(composeState.editing || composeState.voiceMessageRecordingState != .noRecording)
.disabled(composeState.attachmentDisabled)
.frame(width: 25, height: 25)
.padding(.bottom, 12)
.padding(.leading, 12)
@@ -200,15 +264,22 @@ struct ComposeView: View {
sendMessage()
resetLinkPreview()
},
voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed,
sendLiveMessage: sendLiveMessage,
updateLiveMessage: updateLiveMessage,
cancelLiveMessage: {
composeState.liveMessage = nil
chatModel.removeLiveDummy()
},
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: {
Task {
await startVoiceMessageRecording()
}
},
finishVoiceMessageRecording: { finishVoiceMessageRecording() },
allowVoiceMessagesToContact: { allowVoiceMessagesToContact() },
finishVoiceMessageRecording: finishVoiceMessageRecording,
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
keyboardVisible: $keyboardVisible
)
.padding(.trailing, 12)
@@ -233,7 +304,15 @@ struct ComposeView: View {
}
if UIPasteboard.general.hasImages {
Button("Paste image") {
chosenImages = imageList(UIPasteboard.general.image)
UIPasteboard.general.itemProviders.forEach { p in
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
if let url = url, let image = UploadContent.loadFromURL(url: url) {
chosenImages.append(image)
}
}
}
}
}
}
Button("Choose file") {
@@ -260,7 +339,7 @@ struct ComposeView: View {
Task {
var imgs: [String] = []
for image in images {
if let img = resizeImageToStrSize(image, maxDataSize: 14000) {
if let img = resizeImageToStrSize(image.uiImage, maxDataSize: 14000) {
imgs.append(img)
await MainActor.run {
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
@@ -307,6 +386,11 @@ struct ComposeView: View {
if let fileName = composeState.voiceMessageRecordingFileName {
cancelVoiceMessageRecording(fileName)
}
if composeState.liveMessage != nil && (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) {
sendMessage()
resetLinkPreview()
}
chatModel.removeLiveDummy(animated: false)
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingRecording {
@@ -317,7 +401,7 @@ struct ComposeView: View {
startingRecording = false
}
}
.onChange(of: chat.chatInfo.voiceMessageAllowed) { vmAllowed in
.onChange(of: chat.chatInfo.featureEnabled(.voice)) { vmAllowed in
if !vmAllowed && composeState.voicePreview,
let fileName = composeState.voiceMessageRecordingFileName {
cancelVoiceMessageRecording(fileName)
@@ -325,6 +409,60 @@ struct ComposeView: View {
}
}
private func sendLiveMessage() async {
let typedMsg = composeState.message
let lm = composeState.liveMessage
if (composeState.sendEnabled || composeState.quoting)
&& (lm == nil || lm?.sentMsg == nil),
let ci = await sendMessageAsync(typedMsg, live: true) {
await MainActor.run {
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg))
}
} else if lm == nil {
let cItem = chatModel.addLiveDummy(chat.chatInfo)
await MainActor.run {
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: cItem, typedMsg: typedMsg, sentMsg: nil))
}
}
}
private func updateLiveMessage() async {
let typedMsg = composeState.message
if let liveMessage = composeState.liveMessage {
if let sentMsg = liveMessageToSend(liveMessage, typedMsg),
let ci = await sendMessageAsync(sentMsg, live: true) {
await MainActor.run {
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg))
}
} else if liveMessage.typedMsg != typedMsg {
await MainActor.run {
var lm = liveMessage
lm.typedMsg = typedMsg
composeState = composeState.copy(liveMessage: lm)
}
}
}
}
private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? {
let s = t != lm.typedMsg ? truncateToWords(t) : t
return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil
}
private func truncateToWords(_ s: String) -> String {
var acc = ""
var word = ""
for c in s {
if c.isLetter || c.isNumber {
word = word + String(c)
} else {
acc = acc + word + String(c)
word = ""
}
}
return acc
}
@ViewBuilder func previewView() -> some View {
switch composeState.preview {
case .noPreview:
@@ -382,72 +520,59 @@ struct ComposeView: View {
logger.debug("ChatView sendMessage")
Task {
logger.debug("ChatView sendMessage: in Task")
switch composeState.contextItem {
case let .editingItem(chatItem: ei):
if let oldMsgContent = ei.content.msgContent {
do {
await sending()
let mc = updateMsgContent(oldMsgContent)
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
msg: mc
)
await MainActor.run {
clearState()
let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
}
} catch {
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
await MainActor.run {
composeState.disabled = false
composeState.inProgress = false
}
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
}
} else {
await MainActor.run { clearState() }
}
default:
await sending()
var quoted: Int64? = nil
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
quoted = quotedItem.id
}
_ = await sendMessageAsync(nil, live: false)
}
}
switch (composeState.preview) {
case .noPreview:
await send(.text(composeState.message), quoted: quoted)
case .linkPreview:
await send(checkLinkPreview(), quoted: quoted)
case let .imagePreviews(imagePreviews: images):
var text = composeState.message
var sent = false
for i in 0..<min(chosenImages.count, images.count) {
if i > 0 { _ = try? await Task.sleep(nanoseconds: 100_000000) }
if let savedFile = saveImage(chosenImages[i]) {
await send(.image(text: text, image: images[i]), quoted: quoted, file: savedFile)
text = ""
quoted = nil
sent = true
}
}
if !sent {
await send(.text(composeState.message), quoted: quoted)
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName)
case .filePreview:
if let fileURL = chosenFile,
let savedFile = saveFileFromURL(fileURL) {
await send(.file(composeState.message), quoted: quoted, file: savedFile)
private func sendMessageAsync(_ text: String?, live: Bool) async -> ChatItem? {
var sent: ChatItem?
let msgText = text ?? composeState.message
let liveMessage = composeState.liveMessage
if !live {
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
await sending()
}
if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
sent = await updateMessage(liveMessage.chatItem, live: live)
} else {
var quoted: Int64? = nil
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
quoted = quotedItem.id
}
switch (composeState.preview) {
case .noPreview:
sent = await send(.text(msgText), quoted: quoted, live: live)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live)
case let .imagePreviews(imagePreviews: images):
let last = min(chosenImages.count, images.count) - 1
for i in 0..<last {
if let savedFile = saveAnyImage(chosenImages[i]) {
_ = await send(.image(text: "", image: images[i]), quoted: nil, file: savedFile)
}
_ = try? await Task.sleep(nanoseconds: 100_000000)
}
if let savedFile = saveAnyImage(chosenImages[last]) {
sent = await send(.image(text: msgText, image: images[last]), quoted: quoted, file: savedFile, live: live)
}
if sent == nil {
sent = await send(.text(msgText), quoted: quoted, live: live)
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
case .filePreview:
if let fileURL = chosenFile,
let savedFile = saveFileFromURL(fileURL) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live)
}
}
await MainActor.run { clearState() }
}
await MainActor.run { clearState(live: live) }
return sent
func sending() async {
await MainActor.run { composeState.disabled = true }
@@ -456,17 +581,83 @@ struct ComposeView: View {
}
}
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil) async {
func updateMessage(_ ei: ChatItem, live: Bool) async -> ChatItem? {
if let oldMsgContent = ei.content.msgContent {
do {
let mc = updateMsgContent(oldMsgContent)
let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
msg: mc,
live: live
)
await MainActor.run {
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
}
return chatItem
} catch {
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
}
}
return nil
}
func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
switch msgContent {
case .text:
return checkLinkPreview()
case .link:
return checkLinkPreview()
case .image(_, let image):
return .image(text: msgText, image: image)
case .voice(_, let duration):
return .voice(text: msgText, duration: duration)
case .file:
return .file(msgText)
case .unknown(let type, _):
return .unknown(type: type, text: msgText)
}
}
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? {
if let chatItem = await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc
msg: mc,
live: live
) {
await MainActor.run {
chatModel.removeLiveDummy(animated: false)
chatModel.addChatItem(chat.chatInfo, chatItem)
}
return chatItem
}
return nil
}
func checkLinkPreview() -> MsgContent {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
if let url = parseMessage(msgText),
let linkPreview = linkPreview,
url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview)
} else {
return .text(msgText)
}
default:
return .text(msgText)
}
}
func saveAnyImage(_ img: UploadContent) -> String? {
switch img {
case let .simpleImage(image): return saveImage(image)
case let .animatedImage(image): return saveAnimImage(image)
}
}
}
@@ -523,19 +714,7 @@ struct ComposeView: View {
private func allowVoiceMessagesToContact() {
if case let .direct(contact) = chat.chatInfo {
Task {
do {
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
prefs.voice = Preference(allow: .yes)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
chatModel.updateContact(toContact)
}
}
} catch {
logger.error("ComposeView allowVoiceMessagesToContact, apiSetContactPrefs error: \(responseError(error))")
}
}
allowFeatureToContact(contact, .voice)
}
}
@@ -559,12 +738,14 @@ struct ComposeView: View {
clearState()
}
private func clearState() {
composeState = ComposeState()
linkUrl = nil
prevLinkUrl = nil
pendingLinkUrl = nil
cancelledLinks = []
private func clearState(live: Bool = false) {
if live {
composeState.disabled = false
composeState.inProgress = false
} else {
composeState = ComposeState()
resetLinkPreview()
}
chosenImages = []
chosenFile = nil
audioRecorder = nil
@@ -572,23 +753,6 @@ struct ComposeView: View {
startingRecording = false
}
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
switch msgContent {
case .text:
return checkLinkPreview()
case .link:
return checkLinkPreview()
case .image(_, let image):
return .image(text: composeState.message, image: image)
case .voice(_, let duration):
return .voice(text: composeState.message, duration: duration)
case .file:
return .file(composeState.message)
case .unknown(let type, _):
return .unknown(type: type, text: composeState.message)
}
}
private func showLinkPreview(_ s: String) {
prevLinkUrl = linkUrl
linkUrl = parseMessage(s)
@@ -648,21 +812,6 @@ struct ComposeView: View {
pendingLinkUrl = nil
cancelledLinks = []
}
private func checkLinkPreview() -> MsgContent {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
if let url = parseMessage(composeState.message),
let linkPreview = linkPreview,
url == linkPreview.uri {
return .link(text: composeState.message, preview: linkPreview)
} else {
return .text(composeState.message)
}
default:
return .text(composeState.message)
}
}
}
struct ComposeView_Previews: PreviewProvider {
@@ -0,0 +1,150 @@
//
// NativeTextEditor.swift
// SimpleX (iOS)
//
// Created by Avently on 15.12.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SwiftyGif
import SimpleXChat
import PhotosUI
struct NativeTextEditor: UIViewRepresentable {
@Binding var text: String
let height: CGFloat
let font: UIFont
@FocusState.Binding var focused: Bool
let alignment: TextAlignment
let onImagesAdded: ([UploadContent]) -> Void
func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField()
field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right
field.autocapitalizationType = .sentences
field.setOnTextChangedListener { newText, images in
text = newText
if !images.isEmpty {
onImagesAdded(images)
}
}
field.setOnFocusChangedListener { focused = $0 }
field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
return field
}
func updateUIView(_ field: UITextView, context: Context) {
field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right
}
}
private class CustomUITextField: UITextView, UITextViewDelegate {
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in }
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
self.onTextChanged = onTextChanged
}
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
self.onFocusChanged = onFocusChanged
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)}
return UIMenu(children: suggestedActions.map { elem in
if let elem = elem as? UIMenu {
var actions = elem.children
// Replacing Paste action since it allows to paste animated images too
let pasteIndex = elem.children.firstIndex { elem in elem.debugDescription.contains("Action: paste:")}
if let pasteIndex = pasteIndex {
let paste = actions[pasteIndex]
actions.remove(at: pasteIndex)
let newPaste = UIAction(title: paste.title, image: paste.image) { action in
var images: [UploadContent] = []
var totalImages = 0
var processed = 0
UIPasteboard.general.itemProviders.forEach { p in
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
totalImages += 1
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
processed += 1
if let url = url, let image = UploadContent.loadFromURL(url: url) {
images.append(image)
DispatchQueue.main.sync {
self.onTextChanged(textView.text, images)
}
}
// No images were added, just paste a text then
if processed == totalImages && images.isEmpty {
textView.paste(UIPasteboard.general.string)
}
}
}
}
}
actions.insert(newPaste, at: 0)
}
return UIMenu(title: elem.title, subtitle: elem.subtitle, image: elem.image, identifier: elem.identifier, options: elem.options, children: actions)
} else {
return elem
}
})
}
func textViewDidChange(_ textView: UITextView) {
var images: [UploadContent] = []
var rangeDiff = 0
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
textView.attributedText.enumerateAttribute(
NSAttributedString.Key.attachment,
in: NSRange(location: 0, length: textView.attributedText.length),
options: [],
using: { value, range, _ in
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
do {
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
} catch {
if let img = (value as? NSTextAttachment)?.image {
images.append(.simpleImage(image: img))
}
}
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
rangeDiff += range.length
}
}
)
if textView.attributedText != newAttributedText {
textView.attributedText = newAttributedText
}
onTextChanged(textView.text, images)
}
func textViewDidBeginEditing(_ textView: UITextView) {
onFocusChanged(true)
}
func textViewDidEndEditing(_ textView: UITextView) {
onFocusChanged(false)
}
}
struct NativeTextEditor_Previews: PreviewProvider{
static var previews: some View {
@FocusState var keyboardVisible: Bool
return NativeTextEditor(
text: Binding.constant("Hello, world!"),
height: 100,
font: UIFont.preferredFont(forTextStyle: .body),
focused: $keyboardVisible,
alignment: TextAlignment.leading,
onImagesAdded: { _ in }
)
}
}
@@ -9,22 +9,32 @@
import SwiftUI
import SimpleXChat
private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View {
@Binding var composeState: ComposeState
var sendMessage: () -> Void
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
var cancelLiveMessage: (() -> Void)? = nil
var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
var startVoiceMessageRecording: (() -> Void)? = nil
var finishVoiceMessageRecording: (() -> Void)? = nil
var allowVoiceMessagesToContact: (() -> Void)? = nil
var onImagesAdded: ([UploadContent]) -> Void
@State private var holdingVMR = false
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
@State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1
var maxHeight: CGFloat = 360
var minHeight: CGFloat = 37
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
ZStack {
@@ -44,19 +54,25 @@ struct SendMessageView: View {
.lineLimit(10)
.font(teFont)
.multilineTextAlignment(alignment)
// put text on top (after NativeTextEditor) and set color to precisely align it on changes
// .foregroundColor(.red)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.top, 8)
.padding(.bottom, 6)
.matchedGeometryEffect(id: "te", in: namespace)
.background(GeometryReader(content: updateHeight))
TextEditor(text: $composeState.message)
.focused($keyboardVisible)
.font(teFont)
.textInputAutocapitalization(.sentences)
.multilineTextAlignment(alignment)
.padding(.horizontal, 5)
.allowsTightening(false)
.frame(height: teHeight)
NativeTextEditor(
text: $composeState.message,
height: teHeight,
font: teUiFont,
focused: $keyboardVisible,
alignment: alignment,
onImagesAdded: onImagesAdded
)
.allowsTightening(false)
.frame(height: teHeight)
}
}
@@ -67,23 +83,35 @@ struct SendMessageView: View {
.padding([.bottom, .trailing], 3)
} else {
let vmrs = composeState.voiceMessageRecordingState
if showVoiceMessageButton,
composeState.message.isEmpty,
!composeState.editing,
(composeState.noPreview && vmrs == .noRecording)
|| (vmrs == .recording && holdingVMR) {
if voiceMessageAllowed {
RecordVoiceMessageButton(
startVoiceMessageRecording: startVoiceMessageRecording,
finishVoiceMessageRecording: finishVoiceMessageRecording,
holdingVMR: $holdingVMR,
disabled: composeState.disabled
)
} else {
voiceMessageNotAllowedButton()
if showVoiceMessageButton
&& composeState.message.isEmpty
&& !composeState.editing
&& composeState.liveMessage == nil
&& ((composeState.noPreview && vmrs == .noRecording)
|| (vmrs == .recording && holdingVMR)) {
HStack {
if voiceMessageAllowed {
RecordVoiceMessageButton(
startVoiceMessageRecording: startVoiceMessageRecording,
finishVoiceMessageRecording: finishVoiceMessageRecording,
holdingVMR: $holdingVMR,
disabled: composeState.disabled
)
} else {
voiceMessageNotAllowedButton()
}
if let send = sendLiveMessage,
let update = updateLiveMessage,
case .noContextItem = composeState.contextItem {
startLiveMessageButton(send: send, update: update)
}
}
} else if vmrs == .recording && !holdingVMR {
finishVoiceMessageRecordingButton()
} else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty {
cancelLiveMessageButton {
cancelLiveMessage?()
}
} else {
sendMessageButton()
}
@@ -97,19 +125,40 @@ struct SendMessageView: View {
.padding(.vertical, 8)
}
private func sendMessageButton() -> some View {
Button(action: { sendMessage() }) {
Image(systemName: composeState.editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
@ViewBuilder private func sendMessageButton() -> some View {
let v = Button(action: sendMessage) {
Image(systemName: composeState.editing || composeState.liveMessage != nil
? "checkmark.circle.fill"
: "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
.frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity)
}
.disabled(
!composeState.sendEnabled ||
composeState.disabled ||
(!voiceMessageAllowed && composeState.voicePreview)
(!voiceMessageAllowed && composeState.voicePreview) ||
composeState.endLiveDisabled
)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
if composeState.liveMessage == nil,
case .noContextItem = composeState.contextItem,
!composeState.voicePreview && !composeState.editing,
let send = sendLiveMessage,
let update = updateLiveMessage {
v.contextMenu{
Button {
startLiveMessage(send: send, update: update)
} label: {
Label("Send live message", systemImage: "bolt.fill")
}
}
.padding([.bottom, .trailing], 4)
} else {
v.padding([.bottom, .trailing], 4)
}
}
private struct RecordVoiceMessageButton: View {
@@ -146,7 +195,7 @@ struct SendMessageView: View {
}
private func voiceMessageNotAllowedButton() -> some View {
Button(action: {
Button {
switch showEnableVoiceMessagesAlert {
case .userEnable:
AlertManager.shared.showAlert(Alert(
@@ -173,7 +222,7 @@ struct SendMessageView: View {
message: "Please check yours and your contact preferences."
)
}
}) {
} label: {
Image(systemName: "mic")
.foregroundColor(.secondary)
}
@@ -182,6 +231,81 @@ struct SendMessageView: View {
.padding([.bottom, .trailing], 4)
}
private func cancelLiveMessageButton(cancel: @escaping () -> Void) -> some View {
return Button {
cancel()
} label: {
Image(systemName: "multiply")
.resizable()
.scaledToFit()
.foregroundColor(.accentColor)
.frame(width: 15, height: 15)
}
.frame(width: 29, height: 29)
.padding([.bottom, .horizontal], 4)
}
private func startLiveMessageButton(send: @escaping () async -> Void, update: @escaping () async -> Void) -> some View {
return Button {
switch composeState.preview {
case .noPreview: startLiveMessage(send: send, update: update)
default: ()
}
} label: {
Image(systemName: "bolt.fill")
.resizable()
.scaledToFit()
.foregroundColor(.accentColor)
.frame(width: 20, height: 20)
}
.frame(width: 29, height: 29)
.padding([.bottom, .horizontal], 4)
}
private func startLiveMessage(send: @escaping () async -> Void, update: @escaping () async -> Void) {
if liveMessageAlertShown {
start()
} else {
AlertManager.shared.showAlert(Alert(
title: Text("Live message!"),
message: Text("Send a live message - it will update for the recipient(s) as you type it"),
primaryButton: .default(Text("Send")) {
liveMessageAlertShown = true
start()
},
secondaryButton: .cancel()
))
}
func start() {
Task {
await send()
await MainActor.run { run() }
}
}
@Sendable func run() {
Timer.scheduledTimer(withTimeInterval: 0.75, repeats: true) { t in
withAnimation(.easeInOut(duration: 0.7)) {
sendButtonSize = sendButtonSize == 29 ? 26 : 29
sendButtonOpacity = sendButtonOpacity == 1 ? 0.75 : 1
}
if composeState.liveMessage == nil {
t.invalidate()
sendButtonSize = 29
sendButtonOpacity = 1
}
}
Task {
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
while composeState.liveMessage != nil {
await update()
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
}
}
}
}
private func finishVoiceMessageRecordingButton() -> some View {
Button(action: { finishVoiceMessageRecording?() }) {
Image(systemName: "stop.fill")
@@ -195,11 +319,11 @@ struct SendMessageView: View {
private func updateHeight(_ g: GeometryProxy) -> Color {
DispatchQueue.main.async {
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
teFont = isShortEmoji(composeState.message)
? composeState.message.count < 4
? largeEmojiFont
: mediumEmojiFont
: .body
(teFont, teUiFont) = isShortEmoji(composeState.message)
? composeState.message.count < 4
? (largeEmojiFont, largeEmojiUIFont)
: (mediumEmojiFont, mediumEmojiUIFont)
: (.body, UIFont.preferredFont(forTextStyle: .body))
}
return Color.clear
}
@@ -220,6 +344,7 @@ struct SendMessageView_Previews: PreviewProvider {
SendMessageView(
composeState: $composeStateNew,
sendMessage: {},
onImagesAdded: { _ in },
keyboardVisible: $keyboardVisible
)
}
@@ -229,6 +354,7 @@ struct SendMessageView_Previews: PreviewProvider {
SendMessageView(
composeState: $composeStateEditing,
sendMessage: {},
onImagesAdded: { _ in },
keyboardVisible: $keyboardVisible
)
}
@@ -22,6 +22,7 @@ struct ContactPreferencesView: View {
VStack {
List {
timedMessagesFeatureSection()
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
@@ -48,9 +49,10 @@ struct ContactPreferencesView: View {
}
}
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference<SimplePreference>, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
let enabled = FeatureEnabled.enabled(
user: Preference(allow: allowFeature.wrappedValue.allowed),
asymmetric: feature.asymmetric,
user: SimplePreference(allow: allowFeature.wrappedValue.allowed),
contact: pref.contactPreference
)
return Section {
@@ -61,16 +63,51 @@ struct ContactPreferencesView: View {
}
.frame(height: 36)
infoRow("Contact allows", pref.contactPreference.allow.text)
} header: {
HStack {
Image(systemName: "\(feature.icon).fill")
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
Text(feature.text)
}
} footer: {
Text(feature.enabledDescription(enabled))
.frame(height: 36, alignment: .topLeading)
}
header: { featureHeader(feature, enabled) }
footer: { featureFooter(feature, enabled) }
}
private func timedMessagesFeatureSection() -> some View {
let pref = contact.mergedPreferences.timedMessages
let enabled = FeatureEnabled.enabled(
asymmetric: ChatFeature.timedMessages.asymmetric,
user: TimedMessagesPreference(allow: featuresAllowed.timedMessagesAllowed ? .yes : .no),
contact: pref.contactPreference
)
return Section {
Toggle("You allow", isOn: $featuresAllowed.timedMessagesAllowed)
.onChange(of: featuresAllowed.timedMessagesAllowed) { allow in
if allow {
if featuresAllowed.timedMessagesTTL == nil {
featuresAllowed.timedMessagesTTL = 86400
}
} else {
featuresAllowed.timedMessagesTTL = currentFeaturesAllowed.timedMessagesTTL
}
}
infoRow("Contact allows", pref.contactPreference.allow.text)
if featuresAllowed.timedMessagesAllowed {
timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL)
} else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always {
infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
}
}
header: { featureHeader(.timedMessages, enabled) }
footer: { featureFooter(.timedMessages, enabled) }
}
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
HStack {
Image(systemName: feature.iconFilled)
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
Text(feature.text)
}
}
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
Text(feature.enabledDescription(enabled))
.frame(height: 36, alignment: .topLeading)
}
private func savePreferences() {
@@ -91,6 +128,18 @@ struct ContactPreferencesView: View {
}
}
func timedMessagesTTLPicker(_ selection: Binding<Int?>) -> some View {
Picker("Delete after", selection: selection) {
let selectedTTL = selection.wrappedValue
let ttlValues = TimedMessagesPreference.ttlValues
let values = ttlValues + (ttlValues.contains(selectedTTL) ? [] : [selectedTTL])
ForEach(values, id: \.self) { ttl in
Text(TimedMessagesPreference.ttlText(ttl))
}
}
.frame(height: 36)
}
struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View {
ContactPreferencesView(
+2
View File
@@ -28,4 +28,6 @@ func isShortEmoji(_ str: String) -> Bool {
}
let largeEmojiFont = Font.custom("Emoji", size: 48, relativeTo: .largeTitle)
let largeEmojiUIFont: UIFont = UIFont(name: "Emoji", size: 48) ?? UIFont.systemFont(ofSize: 48)
let mediumEmojiFont = Font.custom("Emoji", size: 36, relativeTo: .largeTitle)
let mediumEmojiUIFont: UIFont = UIFont(name: "Emoji", size: 36) ?? UIFont.systemFont(ofSize: 36)
@@ -34,10 +34,24 @@ struct AddGroupMembersView: View {
}
var body: some View {
NavigationView {
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
if creatingGroup {
NavigationView {
addGroupMembersView()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb?(selectedContacts) }
}
}
}
} else {
addGroupMembersView()
}
}
let v = List {
private func addGroupMembersView() -> some View {
VStack {
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
List {
ChatInfoToolbar(chat: chat, imageSize: 48)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
@@ -80,16 +94,6 @@ struct AddGroupMembersView: View {
}
}
}
if creatingGroup {
v.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb?(selectedContacts) }
}
}
} else {
v.navigationBarHidden(true)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alert in
@@ -18,8 +18,8 @@ struct GroupChatInfoView: View {
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String?
@State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil
@State private var connectionStats: ConnectionStats?
@State private var connectionCode: String?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum GroupChatInfoViewAlert: Identifiable {
@@ -65,28 +65,17 @@ struct GroupChatInfoView: View {
}
memberView(groupInfo.membership, user: true)
ForEach(members) { member in
Button {
Task {
do {
let stats = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
await MainActor.run { connectionStats = stats }
} catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))")
}
await MainActor.run { selectedMember = member }
ZStack {
NavigationLink {
memberInfoView(member.groupMemberId)
} label: {
EmptyView()
}
} label: { memberView(member) }
.opacity(0)
memberView(member)
}
}
}
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
.appSheet(item: $selectedMember, onDismiss: {
selectedMember = nil
connectionStats = nil
}) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
}
Section {
clearChatButton()
@@ -125,7 +114,7 @@ struct GroupChatInfoView: View {
}
}
func groupInfoHeader() -> some View {
private func groupInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
@@ -146,35 +135,32 @@ struct GroupChatInfoView: View {
}
private func addMembersButton() -> some View {
Button {
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showAddMembersSheet = true
NavigationLink {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
ChatModel.shared.groupMembers = apiListMembersSync(groupInfo.groupId)
}
}
} label: {
Label("Invite members", systemImage: "plus")
}
}
func serverImage() -> some View {
private func serverImage() -> some View {
let status = chat.serverInfo.networkStatus
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : .secondary)
}
func memberView(_ member: GroupMember, user: Bool = false) -> some View {
private func memberView(_ member: GroupMember, user: Bool = false) -> some View {
HStack{
ProfileImage(imageStr: member.image)
.frame(width: 38, height: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
Text(member.chatViewName)
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
.foregroundColor(member.memberIncognito ? .indigo : .primary)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
@@ -190,17 +176,36 @@ struct GroupChatInfoView: View {
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
@ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View {
if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) {
GroupMemberInfoView(groupInfo: groupInfo, member: member)
.navigationBarHidden(false)
}
}
private func groupLinkButton() -> some View {
NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Group link", systemImage: "link")
if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus")
} else {
Label("Group link", systemImage: "link")
}
}
}
func editGroupButton() -> some View {
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
groupInfo: $groupInfo,
@@ -213,7 +218,7 @@ struct GroupChatInfoView: View {
}
}
func deleteGroupButton() -> some View {
private func deleteGroupButton() -> some View {
Button(role: .destructive) {
alert = .deleteGroupAlert
} label: {
@@ -222,7 +227,7 @@ struct GroupChatInfoView: View {
}
}
func clearChatButton() -> some View {
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
} label: {
@@ -231,7 +236,7 @@ struct GroupChatInfoView: View {
}
}
func leaveGroupButton() -> some View {
private func leaveGroupButton() -> some View {
Button(role: .destructive) {
alert = .leaveGroupAlert
} label: {
@@ -12,6 +12,7 @@ import SimpleXChat
struct GroupLinkView: View {
var groupId: Int64
@Binding var groupLink: String?
@State private var creatingLink = false
@State private var alert: GroupLinkAlert?
private enum GroupLinkAlert: Identifiable {
@@ -48,18 +49,17 @@ struct GroupLinkView: View {
}
.frame(maxWidth: .infinity)
} else {
Button {
Task {
do {
groupLink = try await apiCreateGroupLink(groupId)
} catch let error {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
let a = getErrorAlert(error, "Error creating group link")
alert = .error(title: a.title, error: a.message)
}
}
} label: { Label("Create link", systemImage: "link.badge.plus") }
Button(action: createGroupLink) {
Label("Create link", systemImage: "link.badge.plus")
}
.frame(maxWidth: .infinity)
.disabled(creatingLink)
.padding(.bottom)
if creatingLink {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity)
}
}
}
.padding()
@@ -74,9 +74,7 @@ struct GroupLinkView: View {
Task {
do {
try await apiDeleteGroupLink(groupId)
await MainActor.run {
groupLink = nil
}
await MainActor.run { groupLink = nil }
} catch let error {
logger.error("GroupLinkView apiDeleteGroupLink: \(responseError(error))")
}
@@ -87,6 +85,31 @@ struct GroupLinkView: View {
return Alert(title: Text(title), message: Text(error))
}
}
.onAppear {
if groupLink == nil && !creatingLink {
createGroupLink()
}
}
}
}
private func createGroupLink() {
Task {
do {
creatingLink = true
let link = try await apiCreateGroupLink(groupId)
await MainActor.run {
creatingLink = false
groupLink = link
}
} catch let error {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
await MainActor.run {
creatingLink = false
let a = getErrorAlert(error, "Error creating group link")
alert = .error(title: a.title, error: a.message)
}
}
}
}
}
@@ -13,8 +13,10 @@ struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var groupInfo: GroupInfo
@Binding var member: GroupMember?
@Binding var connectionStats: ConnectionStats?
@State var member: GroupMember
var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -36,76 +38,95 @@ struct GroupMemberInfoView: View {
}
var body: some View {
NavigationView {
if let member = member {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
if navigation {
NavigationView { groupMemberInfoView() }
} else {
groupMemberInfoView()
}
}
if let contactId = member.memberContactId {
if let chat = chatModel.getContactChat(contactId),
chat.chatInfo.contact?.directContact ?? false {
Section {
private func groupMemberInfoView() -> some View {
VStack {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
if member.memberActive {
Section {
if let contactId = member.memberContactId {
if let chat = chatModel.getContactChat(contactId),
chat.chatInfo.contact?.directOrUsed ?? false {
knownDirectChatButton(chat)
}
} else if groupInfo.fullGroupPreferences.directMessages.on {
Section {
} else if groupInfo.fullGroupPreferences.directMessages.on {
newDirectChatButton(contactId)
}
}
if let code = connectionCode { verifyCodeButton(code) }
}
}
Section("Member") {
infoRow("Group", groupInfo.displayName)
Section("Member") {
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
} else {
infoRow("Role", member.memberRole.text)
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
}
if let connStats = connectionStats {
Section("Servers") {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
if let connStats = connectionStats {
smpServers("Receiving via", connStats.rcvServers)
smpServers("Sending via", connStats.sndServers)
}
}
if member.canBeRemoved(groupInfo: groupInfo) {
Section {
removeMemberButton(member)
}
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
}
smpServers("Receiving via", connStats.rcvServers)
smpServers("Sending via", connStats.sndServers)
}
}
.navigationBarHidden(true)
.onAppear { newRole = member.memberRole }
.onChange(of: newRole) { _ in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
if member.canBeRemoved(groupInfo: groupInfo) {
Section {
removeMemberButton(member)
}
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
}
}
}
.navigationBarHidden(true)
.onAppear {
newRole = member.memberRole
do {
let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
member = mem
connectionStats = stats
connectionCode = code
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
}
.onChange(of: newRole) { _ in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -155,10 +176,15 @@ struct GroupMemberInfoView: View {
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(mem.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
HStack {
if mem.verified {
Image(systemName: "checkmark.shield")
}
Text(mem.displayName)
.font(.largeTitle)
.lineLimit(1)
}
.padding(.bottom, 2)
if mem.fullName != "" && mem.fullName != mem.displayName {
Text(mem.fullName)
.font(.title2)
@@ -168,7 +194,38 @@ struct GroupMemberInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func removeMemberButton(_ mem: GroupMember) -> some View {
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
displayName: member.displayName,
connectionCode: code,
connectionVerified: member.verified,
verify: { code in
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
let (verified, existingCode) = r
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode
member.activeConn?.connectionCode = connCode
if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
chatModel.groupMembers[i].activeConn?.connectionCode = connCode
}
return r
}
return nil
}
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
} label: {
Label(
member.verified ? "View security code" : "Verify security code",
systemImage: member.verified ? "checkmark.shield" : "shield"
)
}
}
private func removeMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
alert = .removeMemberAlert(mem: mem)
} label: {
@@ -230,9 +287,7 @@ struct GroupMemberInfoView: View {
private func switchMemberAddress() {
Task {
do {
if let member = member {
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
}
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
} catch let error {
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing address")
@@ -248,8 +303,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
member: Binding.constant(GroupMember.sampleData),
connectionStats: Binding.constant(nil)
member: GroupMember.sampleData
)
}
}
@@ -22,6 +22,7 @@ struct GroupPreferencesView: View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable)
featureSection(.voice, $preferences.voice.enable)
@@ -35,6 +36,15 @@ struct GroupPreferencesView: View {
}
}
}
.onChange(of: preferences.timedMessages.enable) { enable in
if enable == .on {
if preferences.timedMessages.ttl == nil {
preferences.timedMessages.ttl = 86400
}
} else {
preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl
}
}
.modifier(BackButton {
if currentPreferences == preferences {
dismiss()
@@ -55,19 +65,24 @@ struct GroupPreferencesView: View {
Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
if (groupInfo.canEdit) {
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
if groupInfo.canEdit {
let enable = Binding(
get: { enableFeature.wrappedValue == .on },
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
)
settingsRow(icon, color: color) {
Picker(feature.text, selection: enableFeature) {
ForEach(GroupFeatureEnabled.values) { enable in
Text(enable.text)
}
}
.frame(height: 36)
Toggle(feature.text, isOn: enable)
}
}
else {
if timedOn {
timedMessagesTTLPicker($preferences.timedMessages.ttl)
}
} else {
settingsRow(icon, color: color) {
infoRow(feature.text, enableFeature.wrappedValue.text)
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
}
if timedOn {
infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
}
}
} footer: {
@@ -0,0 +1,60 @@
//
// ScanCodeView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 10/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import CodeScanner
struct ScanCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var connectionVerified: Bool
var verify: (String?) async -> (Bool, String)?
@State private var showCodeError = false
var body: some View {
VStack(alignment: .leading) {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
Text("Scan security code from your contact's app.")
.padding(.top)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(isPresented: $showCodeError) {
Alert(title: Text("Incorrect security code!"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
Task {
if let (ok, _) = await verify(r.string) {
await MainActor.run {
connectionVerified = ok
if ok {
dismiss()
} else {
showCodeError = true
}
}
}
}
case let .failure(e):
logger.error("ScanCodeView.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
}
}
}
struct ScanCodeView_Previews: PreviewProvider {
static var previews: some View {
ScanCodeView(connectionVerified: Binding.constant(true), verify: {_ in nil})
}
}
@@ -0,0 +1,126 @@
//
// VerifyCodeView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 10/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct VerifyCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
var displayName: String
@State var connectionCode: String?
@State var connectionVerified: Bool
var verify: (String?) -> (Bool, String)?
@State private var showCodeError = false
var body: some View {
if let code = connectionCode {
verifyCodeView(code)
}
}
private func verifyCodeView(_ code: String) -> some View {
ScrollView {
let splitCode = splitToParts(code, length: 24)
VStack(alignment: .leading) {
Group {
HStack {
if connectionVerified {
Image(systemName: "checkmark.shield")
.foregroundColor(.secondary)
Text("\(displayName) is verified")
} else {
Text("\(displayName) is not verified")
}
}
.frame(height: 24)
QRCode(uri: code)
.padding(.horizontal)
Text(splitCode)
.multilineTextAlignment(.leading)
.font(.body.monospaced())
.lineLimit(20)
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity, alignment: .center)
Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
.padding(.bottom)
Group {
if connectionVerified {
Button {
verifyCode(nil)
} label: {
Label("Clear verification", systemImage: "shield")
}
.padding()
} else {
HStack {
NavigationLink {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Scan code")
} label: {
Label("Scan code", systemImage: "qrcode")
}
.padding()
Button {
verifyCode(code) { verified in
if !verified { showCodeError = true }
}
} label: {
Label("Mark verified", systemImage: "checkmark.shield")
}
.padding()
.alert(isPresented: $showCodeError) {
Alert(title: Text("Incorrect security code!"))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showShareSheet(items: [splitCode])
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}
.onChange(of: connectionVerified) { _ in
if connectionVerified { dismiss() }
}
}
}
private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) {
if let (verified, existingCode) = verify(code) {
connectionVerified = verified
connectionCode = existingCode
cb?(verified)
}
}
private func splitToParts(_ s: String, length: Int) -> String {
if length >= s.count { return s }
return (0 ... (s.count - 1) / length)
.map { s.dropFirst($0 * length).prefix(length) }
.joined(separator: "\n")
}
}
struct VerifyCodeView_Previews: PreviewProvider {
static var previews: some View {
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
}
}
@@ -14,6 +14,10 @@ struct ChatHelp: View {
@State private var showAddChat = false
var body: some View {
ScrollView { chatHelp() }
}
func chatHelp() -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Thank you for installing SimpleX Chat!")
@@ -44,6 +48,15 @@ struct ChatHelp: View {
Text("**Scan QR code**: to connect to your contact in person or via video call.")
}
.padding(.top, 24)
VStack(alignment: .leading, spacing: 10) {
Text("Markdown in messages")
.font(.title2)
.fontWeight(.bold)
MarkdownHelp()
}
.padding(.top, 24)
}
.padding()
}
@@ -31,6 +31,7 @@ struct ChatListNavLink: View {
@State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false
var body: some View {
switch chat.chatInfo {
@@ -42,6 +43,8 @@ struct ChatListNavLink: View {
contactRequestNavLink(cReq)
case let .contactConnection(cConn):
contactConnectionNavLink(cConn)
case let .invalidJSON(json):
invalidJSONPreview(json)
}
}
@@ -335,6 +338,17 @@ struct ChatListNavLink: View {
}
}
}
private func invalidJSONPreview(_ json: String) -> some View {
Text("invalid chat data")
.foregroundColor(.red)
.padding(4)
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showInvalidJSON = true }
.sheet(isPresented: $showInvalidJSON) {
invalidJSONView(json)
}
}
}
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
@@ -14,11 +14,16 @@ struct ChatListView: View {
// not really used in this view
@State private var showSettings = false
@State private var searchText = ""
@State private var selectedChat: ChatId?
@State private var showAddChat = false
var body: some View {
NavigationView {
NavStackCompat(
isActive: Binding(
get: { ChatModel.shared.chatId != nil },
set: { _ in }
),
destination: chatView
) {
VStack {
if chatModel.chats.isEmpty {
onboardingButtons()
@@ -30,7 +35,6 @@ struct ChatListView: View {
}
}
}
.navigationViewStyle(.stack)
}
var chatList: some View {
@@ -42,7 +46,6 @@ struct ChatListView: View {
}
}
.onChange(of: chatModel.chatId) { _ in
selectedChat = chatModel.chatId
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
chatModel.chatToTop = nil
chatModel.popChat(chatId)
@@ -77,15 +80,6 @@ struct ChatListView: View {
}
}
}
.background(
NavigationLink(
destination: chatView(selectedChat),
isActive: Binding(
get: { selectedChat != nil },
set: { _, _ in selectedChat = nil }
)
) { EmptyView() }
)
}
private func onboardingButtons() -> some View {
@@ -131,8 +125,8 @@ struct ChatListView: View {
.clipShape(RoundedRectangle(cornerRadius: 16))
}
@ViewBuilder private func chatView(_ chatId: ChatId?) -> some View {
if let chatId = chatId, let chat = chatModel.getChat(chatId) {
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
@@ -79,26 +79,33 @@ struct ChatPreviewView: View {
}
@ViewBuilder private func chatPreviewTitle() -> some View {
let v = Text(chat.chatInfo.chatViewName)
.font(.title3)
.fontWeight(.bold)
.lineLimit(1)
.frame(alignment: .topLeading)
switch (chat.chatInfo) {
case .direct:
v.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case .group(groupInfo: let groupInfo):
let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold)
switch chat.chatInfo {
case let .direct(contact):
previewTitle(contact.verified == true ? verifiedIcon + t : t)
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case let .group(groupInfo):
let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) {
case .memInvited:
chat.chatInfo.incognito ? v.foregroundColor(.indigo) : v.foregroundColor(.accentColor)
case .memAccepted:
v.foregroundColor(.secondary)
case .memInvited: v.foregroundColor(chat.chatInfo.incognito ? .indigo : .accentColor)
case .memAccepted: v.foregroundColor(.secondary)
default: v
}
default: v
default: previewTitle(t)
}
}
private func previewTitle(_ t: Text) -> some View {
t.lineLimit(1).frame(alignment: .topLeading)
}
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View {
if let cItem = cItem {
let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
+1 -1
View File
@@ -35,7 +35,7 @@ private struct SheetForItem<T, C>: ViewModifier where T: Identifiable, C: View {
}
private struct PrivacySensitive: ViewModifier {
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@Environment(\.scenePhase) var scenePhase
func body(content: Content) -> some View {
@@ -11,10 +11,10 @@ import UIKit
import SwiftUI
extension View {
func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View {
func uiKitContextMenu(menu: Binding<UIMenu>) -> some View {
self.overlay(Color(uiColor: .systemBackground))
.overlay(
InteractionView(content: self, menu: UIMenu(title: title, children: actions))
InteractionView(content: self, menu: menu)
)
}
}
@@ -26,7 +26,7 @@ private struct InteractionConfig<Content: View> {
private struct InteractionView<Content: View>: UIViewRepresentable {
let content: Content
let menu: UIMenu
@Binding var menu: UIMenu
func makeUIView(context: Context) -> UIView {
let view = UIView()

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