mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc96e95849 | |||
| 8a301567e1 | |||
| afa6083cb3 | |||
| 160c253966 | |||
| 3d7434c52f | |||
| c78df15cd6 | |||
| 701bef2a50 | |||
| 560d9b1b03 | |||
| b82349164f | |||
| 30df42d20d | |||
| 01345cd423 | |||
| 38a2f3b458 | |||
| f739c7f226 | |||
| 28fb67fe92 | |||
| d2b643de01 | |||
| bae9685950 | |||
| ccd34f5ab1 | |||
| dd8081a9d4 | |||
| d65909cb53 | |||
| e99b543c2f | |||
| 312d315d73 | |||
| cdf5520084 | |||
| 170e8ddeaf | |||
| 68f7956512 | |||
| e424b3eb66 | |||
| fb7a414e3c | |||
| 5966d6d648 | |||
| c6ebc9a4d9 | |||
| 05ec8b0676 | |||
| 6980f643ff | |||
| b60dd3e3e8 | |||
| 17a5770442 | |||
| 439bbc15a5 | |||
| 3688141972 | |||
| d79f8f8345 | |||
| 923ad7470c | |||
| 833abb252e | |||
| c60207f211 | |||
| d5665a421f | |||
| b2aa0ffc3c | |||
| e50a5cfaa1 | |||
| 73ec44a9be | |||
| 59fe6ab8ce | |||
| ec64be7c89 | |||
| 41984e56c0 | |||
| c435510b2e | |||
| de65d0e0a2 | |||
| 9cde37c6f5 | |||
| 9514acf6aa | |||
| 40d54f3f16 | |||
| 3249c5e463 | |||
| e440fba582 | |||
| 43cedbce26 | |||
| ebad009553 | |||
| 35cf113d98 | |||
| e82d2cbea2 | |||
| a0e056994e | |||
| e4d8ea17fc | |||
| e69db17804 | |||
| 9282cca396 | |||
| ad2eaeb004 | |||
| 3f44c9af24 | |||
| 4296515473 | |||
| 88830a2e09 | |||
| c8a269e391 | |||
| a45897007c | |||
| 42ea1df342 | |||
| 47214e33ea | |||
| ff239d81e5 | |||
| 0a6e47dd61 | |||
| ee75219ed3 | |||
| 2e337a753f | |||
| 9918d91def | |||
| 82dd5751c1 | |||
| de637fab50 | |||
| cb49f6f01d | |||
| 6d0a83aa58 | |||
| 05f57e98ad | |||
| 6a66525927 | |||
| 9530a6055a | |||
| f5ed8debcc | |||
| 355d2449c5 | |||
| f7382cdd6f | |||
| dc8d10d068 | |||
| 951245d33f | |||
| 521b901cc9 | |||
| 6311ba451b | |||
| 754c76d6fd | |||
| aea7ff1c89 | |||
| 77ac972e09 | |||
| 256f85024f | |||
| 870f9e42dd | |||
| 0a4a3a24e1 | |||
| 8a66390a78 | |||
| 3d48eded3d | |||
| 6546426ec0 | |||
| 4a7ceb00fb | |||
| 53560378bb | |||
| cdb3b6aafd | |||
| 9f3d3e8ba4 | |||
| 047aad592e | |||
| 087acd9180 | |||
| 0b822e4a5c | |||
| f8a469488e | |||
| 3b5e806418 | |||
| 79e208193a | |||
| ef5c13b1c1 | |||
| 38533213d2 | |||
| 5f1aa6fa9d | |||
| b0002fe07d | |||
| ef21fd1d26 | |||
| 4a311b9578 | |||
| b8da5e225b | |||
| f27de052cf | |||
| 5cc537f14c | |||
| dc8ca4cf89 | |||
| b62dd801f1 | |||
| 0c096e2c89 | |||
| cc127e56fe | |||
| 1781495ee3 | |||
| 831231d8e6 | |||
| 45102442f4 | |||
| f323c8e112 | |||
| 3bdc6b5e28 | |||
| d8373262bc | |||
| 3597d34716 | |||
| bd4259e89e | |||
| 55ead740cc | |||
| 5ef0eda2d7 | |||
| 49a9b0e7d6 | |||
| 45ada450a2 | |||
| 307a1b3c5e | |||
| ed6b3bbead | |||
| 901610eec5 | |||
| 7d4127c51d | |||
| 13215d91d7 | |||
| e1a8099474 | |||
| daa8d9bb21 | |||
| 5fcbade1bc | |||
| 3937ffa9a6 | |||
| 80ddb50e1c | |||
| f6e66f1c53 | |||
| 0c23ff9ae3 | |||
| 1570bc2b99 | |||
| 1e2104cabf | |||
| f3014f258d | |||
| f0991cc0ba | |||
| 74b78a8d7b | |||
| 82cd70a75c | |||
| fe4eb7b5af | |||
| c459e71d02 | |||
| 2516d5a393 | |||
| 477d98d75a | |||
| 4253cd7fb9 | |||
| ca78958667 | |||
| 1f5b80d560 | |||
| 2de111e76c | |||
| 8343285d93 | |||
| 5dbe2b2745 | |||
| fb9485190d | |||
| 6881600e06 | |||
| 9ed723bafa | |||
| 9ded1c9821 | |||
| bb374c68b1 | |||
| c3e82a6a4e | |||
| 7c12e82042 | |||
| e7e66ff873 | |||
| c4d7e5307c | |||
| d6b9a45a39 | |||
| 7fd3b4d6ba | |||
| 4004aafbc5 | |||
| 95008eeeaf | |||
| c7a8992043 | |||
| ea2b5f2ccf | |||
| ed9f277421 | |||
| 5c14c3b349 | |||
| d8fb31f167 | |||
| 02db38ffd3 | |||
| 7692195bfa | |||
| c435cbdc7b | |||
| effc281271 | |||
| 41eb2e5689 | |||
| 67d74a0a27 | |||
| f66405e79b | |||
| 74d186af16 | |||
| 187fef0c5a | |||
| 4782cab507 | |||
| bcbee67709 | |||
| 2501cbe55d | |||
| 2bd049db87 | |||
| 6b8b9ab4fd | |||
| 30db24265e | |||
| 316d605899 | |||
| b4257f7767 | |||
| a3f2d5c919 | |||
| cf46469cd5 | |||
| 0312fde818 | |||
| 9defa44f0c | |||
| 915b53054c | |||
| f81557b4fd | |||
| e273bd1239 | |||
| a63caf4640 | |||
| e7f0234134 | |||
| 340552321e | |||
| 98a3fc214d | |||
| 6a578cfe3c | |||
| dacc075fe8 | |||
| 55418e2bc0 | |||
| f2b5c0f3a8 | |||
| 5ebdf5dba9 | |||
| 8e045764df | |||
| 503d3d77e6 | |||
| 81bd7d97c5 | |||
| 8f57925067 | |||
| 9bf99db82e | |||
| 5615cdbf1a | |||
| d802ae0058 | |||
| 8f2278198c | |||
| 10937a5a4e | |||
| 6aff6e9804 | |||
| 95477cae7e |
@@ -5,22 +5,12 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- users
|
||||
tags:
|
||||
- "v*"
|
||||
- "!*-fdroid"
|
||||
- "!*-armv7a"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "apps/ios"
|
||||
- "apps/multiplatform"
|
||||
- "blog"
|
||||
- "docs"
|
||||
- "fastlane"
|
||||
- "images"
|
||||
- "packages"
|
||||
- "website"
|
||||
- "README.md"
|
||||
- "PRIVACY.md"
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
|
||||
@@ -61,7 +61,6 @@ website/package/generated*
|
||||
# Ignore build tool output, e.g. code coverage
|
||||
website/.nyc_output/
|
||||
website/coverage/
|
||||
result
|
||||
|
||||
# Ignore API documentation
|
||||
website/api-docs/
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library'
|
||||
RUN cabal build exe:simplex-chat
|
||||
|
||||
# Strip the binary from debug symbols to reduce size
|
||||
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||
|
||||
+2
-4
@@ -25,9 +25,7 @@ While SimpleX Chat Ltd is not a communication service provider, and provide publ
|
||||
|
||||
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
|
||||
|
||||
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits – see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
|
||||
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
### Your information
|
||||
|
||||
@@ -174,4 +172,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
|
||||
|
||||
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
|
||||
|
||||
Updated October 14, 2024
|
||||
Updated April 24, 2024
|
||||
|
||||
@@ -72,7 +72,7 @@ You must:
|
||||
|
||||
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
||||
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
|
||||
|
||||
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
|
||||
|
||||
@@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
|
||||
|
||||
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
||||
|
||||
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%3D%3D%22%7D) (Italian-speaking).
|
||||
[\#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-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-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), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
||||
|
||||
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||
|
||||
@@ -233,12 +233,14 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent and important updates:
|
||||
|
||||
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
|
||||
|
||||
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
|
||||
|
||||
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
|
||||
|
||||
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
|
||||
|
||||
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
|
||||
|
||||
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
|
||||
|
||||
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
|
||||
@@ -247,6 +249,10 @@ Recent and important updates:
|
||||
|
||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
||||
|
||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
|
||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
@@ -294,27 +300,25 @@ What is already implemented:
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation.
|
||||
5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
||||
7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing).
|
||||
11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers.
|
||||
12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||
14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||
15. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||
16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||
17. Local files encryption.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
||||
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||
12. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||
14. Local files encryption.
|
||||
|
||||
We plan to add:
|
||||
|
||||
1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic 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. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
3. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
||||
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||
2. Post-quantum resistant key exchange in double ratchet protocol.
|
||||
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
||||
|
||||
## For developers
|
||||
|
||||
@@ -403,9 +407,7 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
|
||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
||||
|
||||
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits – see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
|
||||
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
|
||||
|
||||
|
||||
@@ -15,11 +15,35 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||
removePasscodesIfReinstalled()
|
||||
prepareForLaunch()
|
||||
return true
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private func trackKeyboard() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillShow(_ notification: Notification) {
|
||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
||||
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillHide(_ notification: Notification) {
|
||||
ChatModel.shared.keyboardHeight = 0
|
||||
}
|
||||
|
||||
@objc func pasteboardChanged() {
|
||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"symbols": [
|
||||
{
|
||||
"filename": "checkmark.2.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>checkmark.2</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="double.checkmark">
|
||||
<g id="Notes">
|
||||
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
|
||||
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="322">Weight/Scale Variations</tspan>
|
||||
</text>
|
||||
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="533.711" y="322">Ultralight</tspan>
|
||||
</text>
|
||||
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="843.422" y="322">Thin</tspan>
|
||||
</text>
|
||||
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1138.63" y="322">Light</tspan>
|
||||
</text>
|
||||
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1426.84" y="322">Regular</tspan>
|
||||
</text>
|
||||
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1723.06" y="322">Medium</tspan>
|
||||
</text>
|
||||
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2015.77" y="322">Semibold</tspan>
|
||||
</text>
|
||||
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2326.48" y="322">Bold</tspan>
|
||||
</text>
|
||||
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2618.19" y="322">Heavy</tspan>
|
||||
</text>
|
||||
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2917.4" y="322">Black</tspan>
|
||||
</text>
|
||||
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="1953">Design Variations</tspan>
|
||||
</text>
|
||||
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
|
||||
</text>
|
||||
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
|
||||
</text>
|
||||
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
|
||||
</text>
|
||||
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
|
||||
</line>
|
||||
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="776" y="1953">Margins</tspan>
|
||||
</text>
|
||||
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
|
||||
</tspan>
|
||||
</text>
|
||||
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
|
||||
</tspan>
|
||||
</text>
|
||||
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
|
||||
</text>
|
||||
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="776" y="2025">scales and weights.</tspan>
|
||||
</text>
|
||||
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1289" y="1953">Exporting</tspan>
|
||||
</text>
|
||||
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
|
||||
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
|
||||
</text>
|
||||
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
|
||||
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
|
||||
</text>
|
||||
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2952" y="1933">Template v.5.0</tspan>
|
||||
</text>
|
||||
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
|
||||
</text>
|
||||
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
|
||||
</text>
|
||||
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
|
||||
</text>
|
||||
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="726">Small</tspan>
|
||||
</text>
|
||||
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="1156">Medium</tspan>
|
||||
</text>
|
||||
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="1586">Large</tspan>
|
||||
</text>
|
||||
</g>
|
||||
<g id="Guides" transform="translate(263, 600.785)">
|
||||
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
</g>
|
||||
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
|
||||
<g id="Black-S" transform="translate(2365.995, 0)">
|
||||
<path
|
||||
d="M67.46878,71.191381 C71.17968,71.191381 74.06058,69.873022 76.01368,66.99216 L111.07228,15.2343 C112.43948,13.2324 113.02538,11.1328 113.02538,9.2773 C113.02538,4.0039 108.82618,0 103.35738,0 C99.69528,0 97.30278,1.3183 95.05668,4.834 L67.32228,47.9492 L53.69918,32.6172 C51.79488,30.4687 49.54888,29.4433 46.52148,29.4433 C41.05278,29.4433 37,33.4472 37,38.7695 C37,41.2109 37.63478,43.1152 39.73438,45.459 L59.36328,67.67576 C61.51168,70.117162 64.14848,71.191381 67.46878,71.191381 Z"
|
||||
id="Path"></path>
|
||||
<path
|
||||
d="M9.52148,29.4433 C12.54888,29.4433 14.79488,30.4687 16.69918,32.6172 L30.32228,47.9492 L32.291,44.888 L44.484,58.915 L39.01368,66.99216 C37.1235832,69.780091 34.3645791,71.1046997 30.825305,71.1872572 L30.46878,71.191381 C27.14848,71.191381 24.51168,70.117162 22.36328,67.67576 L2.73438,45.459 C0.63478,43.1152 0,41.2109 0,38.7695 C0,33.4472 4.05278,29.4433 9.52148,29.4433 Z M66.35738,0 C71.82618,0 76.02538,4.0039 76.02538,9.2773 C76.02538,11.1328 75.43948,13.2324 74.07228,15.2343 L61.951,33.129 L49.252,18.52 L58.05668,4.834 C60.2386057,1.41874857 62.5586852,0.0771252245 66.0465687,0.00324899359 Z"
|
||||
id="Combined-Shape"></path>
|
||||
</g>
|
||||
<g id="Regular-S" transform="translate(886.905, 3.7109)">
|
||||
<path
|
||||
d="M55.87888,66.113294 C57.78318,66.113294 59.29688,65.28322 60.37108,63.62306 L95.96678,7.3242 C96.79688,6.0547 97.08988,5.0293 97.08988,4.0039 C97.08988,1.6113 95.52738,0 93.08598,0 C91.32818,0 90.35158,0.586 89.27738,2.2949 L55.68358,56.2012 L38.00778,32.3731 C36.88478,30.8594 35.81058,30.2246 34.19918,30.2246 C31.75778,30.2246 30,31.9336 30,34.375 C30,35.4004 30.43948,36.5234 31.26958,37.5977 L51.24028,63.5254 C52.60738,65.28322 53.97458,66.113294 55.87888,66.113294 Z"
|
||||
id="Path"></path>
|
||||
<path
|
||||
d="M4.19918,30.2246 C5.81058,30.2246 6.88478,30.8594 8.00778,32.3731 L25.68358,56.2012 L30.332,48.741 L35.554,55.426 L30.37108,63.62306 C29.3457073,65.2077582 27.919881,66.0361177 26.1361316,66.1081489 L25.87888,66.113294 C23.97458,66.113294 22.60738,65.28322 21.24028,63.5254 L1.26958,37.5977 C0.43948,36.5234 0,35.4004 0,34.375 C0,31.9336 1.75778,30.2246 4.19918,30.2246 Z M63.08598,0 C65.52738,0 67.08988,1.6113 67.08988,4.0039 C67.08988,5.0293 66.79688,6.0547 65.96678,7.3242 L48.893,34.328 L43.564,27.508 L59.27738,2.2949 C60.3068217,0.657204167 61.2466272,0.0507828125 62.8702602,0.00309092159 Z"
|
||||
id="Combined-Shape"></path>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="translate(0, 4.4375)">
|
||||
<path
|
||||
d="M36.79298,62.07178 C37.24418,62.07178 37.53178,61.87744 37.78868,61.53417 L76.33598,2.0112 C76.57578,1.6045 76.64168,1.3965 76.64168,1.1885 C76.64168,0.5215 76.12358,0 75.45318,0 C75.01228,0 74.71678,0.1772 74.50538,0.5693 L36.73398,58.97114 L18.24078,37.7768 C17.98048,37.3984 17.67828,37.2177 17.20218,37.2177 C16.48638,37.2177 16,37.7006 16,38.371 C16,38.6699 16.12159,38.9755 16.40678,39.2778 L35.65088,61.52733 C36.01908,61.96826 36.29638,62.07178 36.79298,62.07178 Z"
|
||||
id="Path"></path>
|
||||
<path
|
||||
d="M1.20218,37.2177 C1.67828,37.2177 1.98048,37.3984 2.24078,37.7768 L20.73398,58.97114 L23.078,55.345 L24.636,57.137 L21.78868,61.53417 C21.5603244,61.8392989 21.3077121,62.0267547 20.937503,62.0646445 L20.79298,62.07178 C20.29638,62.07178 20.01908,61.96826 19.65088,61.52733 L0.40678,39.2778 C0.12159,38.9755 0,38.6699 0,38.371 C0,37.7006 0.48638,37.2177 1.20218,37.2177 Z M59.45318,0 C60.12358,0 60.64168,0.5215 60.64168,1.1885 C60.64168,1.3965 60.57578,1.6045 60.33598,2.0112 L32.216,45.432 L30.652,43.634 L58.50538,0.5693 C58.6932911,0.220766667 58.9476516,0.0420308642 59.3115144,0.00661467764 Z"
|
||||
id="Combined-Shape"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"symbols": [
|
||||
{
|
||||
"filename": "checkmark.wide.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>checkmark.wide</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="double.checkmark">
|
||||
<g id="Notes">
|
||||
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
|
||||
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="322">Weight/Scale Variations</tspan>
|
||||
</text>
|
||||
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="533.711" y="322">Ultralight</tspan>
|
||||
</text>
|
||||
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="843.422" y="322">Thin</tspan>
|
||||
</text>
|
||||
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1138.63" y="322">Light</tspan>
|
||||
</text>
|
||||
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1426.84" y="322">Regular</tspan>
|
||||
</text>
|
||||
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1723.06" y="322">Medium</tspan>
|
||||
</text>
|
||||
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2015.77" y="322">Semibold</tspan>
|
||||
</text>
|
||||
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2326.48" y="322">Bold</tspan>
|
||||
</text>
|
||||
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2618.19" y="322">Heavy</tspan>
|
||||
</text>
|
||||
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2917.4" y="322">Black</tspan>
|
||||
</text>
|
||||
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="1953">Design Variations</tspan>
|
||||
</text>
|
||||
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
|
||||
</text>
|
||||
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
|
||||
</text>
|
||||
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
|
||||
</text>
|
||||
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
|
||||
</line>
|
||||
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="776" y="1953">Margins</tspan>
|
||||
</text>
|
||||
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
|
||||
</tspan>
|
||||
</text>
|
||||
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
|
||||
</tspan>
|
||||
</text>
|
||||
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
|
||||
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
|
||||
</text>
|
||||
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="776" y="2025">scales and weights.</tspan>
|
||||
</text>
|
||||
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="1289" y="1953">Exporting</tspan>
|
||||
</text>
|
||||
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
|
||||
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
|
||||
</text>
|
||||
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
|
||||
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
|
||||
</text>
|
||||
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2952" y="1933">Template v.5.0</tspan>
|
||||
</text>
|
||||
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
|
||||
</text>
|
||||
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
|
||||
</text>
|
||||
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||
font-size="13" font-weight="normal">
|
||||
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
|
||||
</text>
|
||||
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="726">Small</tspan>
|
||||
</text>
|
||||
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="1156">Medium</tspan>
|
||||
</text>
|
||||
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||
font-weight="normal">
|
||||
<tspan x="263" y="1586">Large</tspan>
|
||||
</text>
|
||||
</g>
|
||||
<g id="Guides" transform="translate(263, 600.785)">
|
||||
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||
<path
|
||||
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||
id="Shape"></path>
|
||||
</g>
|
||||
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||
</line>
|
||||
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
|
||||
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||
</g>
|
||||
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
|
||||
<g id="Black-S" transform="translate(2365.995, 0)">
|
||||
<path
|
||||
d="M30.46878,71.191381 C34.17968,71.191381 37.06058,69.873022 39.01368,66.99216 L74.07228,15.2343 C75.43948,13.2324 76.02538,11.1328 76.02538,9.2773 C76.02538,4.0039 71.82618,0 66.35738,0 C62.69528,0 60.30278,1.3183 58.05668,4.834 L30.32228,47.9492 L16.69918,32.6172 C14.79488,30.4687 12.54888,29.4433 9.52148,29.4433 C4.05278,29.4433 0,33.4472 0,38.7695 C0,41.2109 0.63478,43.1152 2.73438,45.459 L22.36328,67.67576 C24.51168,70.117162 27.14848,71.191381 30.46878,71.191381 Z"
|
||||
id="Path"></path>
|
||||
</g>
|
||||
<g id="Regular-S" transform="translate(886.905, 3.7109)">
|
||||
<path
|
||||
d="M25.87888,66.113294 C27.78318,66.113294 29.29688,65.28322 30.37108,63.62306 L65.96678,7.3242 C66.79688,6.0547 67.08988,5.0293 67.08988,4.0039 C67.08988,1.6113 65.52738,0 63.08598,0 C61.32818,0 60.35158,0.586 59.27738,2.2949 L25.68358,56.2012 L8.00778,32.3731 C6.88478,30.8594 5.81058,30.2246 4.19918,30.2246 C1.75778,30.2246 0,31.9336 0,34.375 C0,35.4004 0.43948,36.5234 1.26958,37.5977 L21.24028,63.5254 C22.60738,65.28322 23.97458,66.113294 25.87888,66.113294 Z"
|
||||
id="Path"></path>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="translate(0, 4.4375)">
|
||||
<path
|
||||
d="M20.79298,62.07178 C21.24418,62.07178 21.53178,61.87744 21.78868,61.53417 L60.33598,2.0112 C60.57578,1.6045 60.64168,1.3965 60.64168,1.1885 C60.64168,0.5215 60.12358,0 59.45318,0 C59.01228,0 58.71678,0.1772 58.50538,0.5693 L20.73398,58.97114 L2.24078,37.7768 C1.98048,37.3984 1.67828,37.2177 1.20218,37.2177 C0.48638,37.2177 0,37.7006 0,38.371 C0,38.6699 0.12159,38.9755 0.40678,39.2778 L19.65088,61.52733 C20.01908,61.96826 20.29638,62.07178 20.79298,62.07178 Z"
|
||||
id="Path"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 22 KiB |
@@ -13,7 +13,6 @@ struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var callController = CallController.shared
|
||||
@ObservedObject var appSheetState = AppSheetState.shared
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
@@ -30,14 +29,14 @@ struct ContentView: View {
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||
@State private var showSettings = false
|
||||
@State private var showWhatsNew = false
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
|
||||
|
||||
private let callTopPadding: CGFloat = 40
|
||||
private let callTopPadding: CGFloat = 50
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||
@@ -87,7 +86,7 @@ struct ContentView: View {
|
||||
callView(call)
|
||||
}
|
||||
|
||||
if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
|
||||
if !showSettings, let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
.onDisappear {
|
||||
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
|
||||
@@ -110,6 +109,9 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(showSettings: $showSettings)
|
||||
}
|
||||
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
|
||||
Button("System authentication") { initialEnableLA() }
|
||||
Button("Passcode entry") { showSetPasscode = true }
|
||||
@@ -149,12 +151,12 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
reactOnDarkThemeChanges(systemInDarkThemeCurrently)
|
||||
reactOnDarkThemeChanges()
|
||||
}
|
||||
.onChange(of: colorScheme) { scheme in
|
||||
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
|
||||
// so when a user changes his global theme from dark to light or back, the app will adapt to it
|
||||
reactOnDarkThemeChanges(scheme == .dark)
|
||||
reactOnDarkThemeChanges()
|
||||
}
|
||||
.onChange(of: theme.name) { _ in
|
||||
ThemeManager.adjustWindowStyle()
|
||||
@@ -205,7 +207,7 @@ struct ContentView: View {
|
||||
CallDuration(call: call)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(height: callTopPadding)
|
||||
.frame(height: callTopPadding - 10)
|
||||
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||
.onTapGesture {
|
||||
chatModel.activeCallViewIsCollapsed = false
|
||||
@@ -251,8 +253,7 @@ struct ContentView: View {
|
||||
|
||||
private func mainView() -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
|
||||
.redacted(reason: appSheetState.redactionReasons(protectScreen))
|
||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||
.onAppear {
|
||||
requestNtfAuthorization()
|
||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||
@@ -299,18 +300,9 @@ struct ContentView: View {
|
||||
if let contactId = contacts?.first?.personHandle?.value,
|
||||
let chat = chatModel.getChat(contactId),
|
||||
case let .direct(contact) = chat.chatInfo {
|
||||
let activeCall = chatModel.activeCall
|
||||
// This line works when a user clicks on a video button in CallKit UI while in call.
|
||||
// The app tries to make another call to the same contact and overwite activeCall instance making its state broken
|
||||
if let activeCall, contactId == activeCall.contact.id, mediaType == .video, !activeCall.hasVideo {
|
||||
Task {
|
||||
await chatModel.callCommand.processCommand(.media(source: .camera, enable: true))
|
||||
}
|
||||
} else if activeCall == nil {
|
||||
logger.debug("callToRecentContact: schedule call")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
}
|
||||
logger.debug("callToRecentContact: schedule call")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class ItemsModel: ObservableObject {
|
||||
|
||||
init() {
|
||||
publisher
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
||||
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { self.objectWillChange.send() }
|
||||
.store(in: &bag)
|
||||
}
|
||||
@@ -123,14 +123,6 @@ class NetworkModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// ChatItemWithMenu can depend on previous or next item for it's appearance
|
||||
/// This dummy model is used to force an update of all chat items,
|
||||
/// when they might have changed appearance.
|
||||
class ChatItemDummyModel: ObservableObject {
|
||||
static let shared = ChatItemDummyModel()
|
||||
func sendUpdate() { objectWillChange.send() }
|
||||
}
|
||||
|
||||
final class ChatModel: ObservableObject {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@@ -147,12 +139,11 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chatDbEncrypted: Bool?
|
||||
@Published var chatDbStatus: DBMigrationResult?
|
||||
@Published var ctrlInitInProgress: Bool = false
|
||||
@Published var notificationResponse: UNNotificationResponse?
|
||||
// local authentication
|
||||
@Published var contentViewAccessAuthenticated: Bool = false
|
||||
@Published var laRequest: LocalAuthRequest?
|
||||
// list of chat "previews"
|
||||
@Published private(set) var chats: [Chat] = []
|
||||
@Published var chats: [Chat] = []
|
||||
@Published var deletedChats: Set<String> = []
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@@ -192,6 +183,9 @@ final class ChatModel: ObservableObject {
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
// tracks keyboard height via subscription in AppDelegate
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
@@ -365,8 +359,25 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateChats(_ newChats: [ChatData]) {
|
||||
chats = newChats.map { Chat($0) }
|
||||
func updateChats(with newChats: [ChatData]) {
|
||||
for i in 0..<newChats.count {
|
||||
let c = newChats[i]
|
||||
if let j = getChatIndex(c.id) {
|
||||
let chat = chats[j]
|
||||
chat.chatInfo = c.chatInfo
|
||||
chat.chatItems = c.chatItems
|
||||
chat.chatStats = c.chatStats
|
||||
if i != j {
|
||||
if chatId != c.chatInfo.id {
|
||||
popChat_(j, to: i)
|
||||
} else if i == 0 {
|
||||
chatToTop = c.chatInfo.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addChat_(Chat(c), at: i)
|
||||
}
|
||||
}
|
||||
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
||||
popChatCollector.clear()
|
||||
}
|
||||
@@ -436,17 +447,19 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
ChatItemDummyModel.shared.sendUpdate()
|
||||
withConditionalAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
var ci = cItem
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
withConditionalAnimation(itemAnimation()) {
|
||||
var ci = cItem
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
}
|
||||
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
im.itemAdded = true
|
||||
}
|
||||
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
im.itemAdded = true
|
||||
ChatItemDummyModel.shared.sendUpdate()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -527,7 +540,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
|
||||
guard var current = currentUser, current.uiThemes != uiThemes else { return }
|
||||
guard var current = currentUser else { return }
|
||||
current.uiThemes = uiThemes
|
||||
let i = users.firstIndex(where: { $0.user.userId == current.userId })
|
||||
if let i {
|
||||
@@ -563,7 +576,6 @@ final class ChatModel: ObservableObject {
|
||||
// update preview
|
||||
_updateChat(cInfo.id) { chat in
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
|
||||
self.updateFloatingButtons(unreadCount: 0)
|
||||
chat.chatStats = ChatStats()
|
||||
}
|
||||
// update current chat
|
||||
@@ -580,12 +592,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFloatingButtons(unreadCount: Int) {
|
||||
let fbm = ChatView.FloatingButtonModel.shared
|
||||
fbm.totalUnread = unreadCount
|
||||
fbm.objectWillChange.send()
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||
if let cItem = aboveItem {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
@@ -604,7 +610,6 @@ final class ChatModel: ObservableObject {
|
||||
if markedCount > 0 {
|
||||
chat.chatStats.unreadCount -= markedCount
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
|
||||
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -634,15 +639,19 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
|
||||
if self.chatId == cInfo.id {
|
||||
for itemId in itemIds {
|
||||
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
|
||||
markChatItemRead_(i)
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
if chatId == cInfo.id,
|
||||
let itemIndex = getChatItemIndex(cItem),
|
||||
im.reversedChatItems[itemIndex].isRcvNew {
|
||||
await MainActor.run {
|
||||
withTransaction(Transaction()) {
|
||||
// update current chat
|
||||
markChatItemRead_(itemIndex)
|
||||
// update preview
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
|
||||
}
|
||||
|
||||
private let unreadCollector = UnreadCollector()
|
||||
@@ -668,10 +677,9 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
|
||||
if chatId == ChatModel.shared.chatId {
|
||||
ChatView.FloatingButtonModel.shared.totalUnread += count
|
||||
DispatchQueue.main.async {
|
||||
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||
}
|
||||
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||
subject.send()
|
||||
}
|
||||
}
|
||||
@@ -893,6 +901,35 @@ final class ChatModel: ObservableObject {
|
||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
||||
var i = 0
|
||||
var totalBelow = 0
|
||||
var unreadBelow = 0
|
||||
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
|
||||
totalBelow += 1
|
||||
if im.reversedChatItems[i].isRcvNew {
|
||||
unreadBelow += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return UnreadChatItemCounts(
|
||||
// TODO these thresholds account for the fact that items are still "visible" while
|
||||
// covered by compose area, they should be replaced with the actual height in pixels below the screen.
|
||||
isNearBottom: totalBelow < 15,
|
||||
isReallyNearBottom: totalBelow < 2,
|
||||
unreadBelow: unreadBelow
|
||||
)
|
||||
}
|
||||
|
||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
||||
let maxIx = im.reversedChatItems.count - 1
|
||||
var i = 0
|
||||
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
|
||||
while i < maxIx && !inView(i) { i += 1 }
|
||||
while i < maxIx && inView(i) { i += 1 }
|
||||
return im.reversedChatItems[min(i - 1, maxIx)]
|
||||
}
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
@@ -905,6 +942,12 @@ struct NTFContactRequest {
|
||||
var chatId: String
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts: Equatable {
|
||||
var isNearBottom: Bool
|
||||
var isReallyNearBottom: Bool
|
||||
var unreadBelow: Int
|
||||
}
|
||||
|
||||
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
|
||||
@@ -29,33 +29,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
private var granted = false
|
||||
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
// Handle notification when app is in background
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler handler: () -> Void) {
|
||||
logger.debug("NtfManager.userNotificationCenter: didReceive")
|
||||
if appStateGroupDefault.get() == .active {
|
||||
processNotificationResponse(response)
|
||||
} else {
|
||||
logger.debug("NtfManager.userNotificationCenter: remember response in model")
|
||||
ChatModel.shared.notificationResponse = response
|
||||
}
|
||||
handler()
|
||||
}
|
||||
|
||||
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
|
||||
let content = response.notification.request.content
|
||||
let chatModel = ChatModel.shared
|
||||
let content = ntfResponse.notification.request.content
|
||||
let action = ntfResponse.actionIdentifier
|
||||
logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
|
||||
let action = response.actionIdentifier
|
||||
logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
|
||||
if let userId = content.userInfo["userId"] as? Int64,
|
||||
userId != chatModel.currentUser?.userId {
|
||||
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
|
||||
changeActiveUser(userId, viewPwd: nil)
|
||||
}
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
|
||||
@@ -77,6 +61,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
ItemsModel.shared.loadOpenChat(chatId)
|
||||
}
|
||||
}
|
||||
handler()
|
||||
}
|
||||
|
||||
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
|
||||
@@ -91,6 +76,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Handle notification when the app is in foreground
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
@@ -199,12 +185,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryManyEvents,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
|
||||
)
|
||||
])
|
||||
}
|
||||
@@ -230,28 +210,29 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(createContactRequestNtf(user, contactRequest, 0))
|
||||
addNotification(createContactRequestNtf(user, contactRequest))
|
||||
}
|
||||
|
||||
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
|
||||
logger.debug("NtfManager.notifyContactConnected")
|
||||
addNotification(createContactConnectedNtf(user, contact, 0))
|
||||
addNotification(createContactConnectedNtf(user, contact))
|
||||
}
|
||||
|
||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
if cInfo.ntfsEnabled {
|
||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
|
||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
|
||||
}
|
||||
}
|
||||
|
||||
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
|
||||
logger.debug("NtfManager.notifyCallInvitation")
|
||||
addNotification(createCallInvitationNtf(invitation, 0))
|
||||
addNotification(createCallInvitationNtf(invitation))
|
||||
}
|
||||
|
||||
func setNtfBadgeCount(_ count: Int) {
|
||||
|
||||
@@ -112,9 +112,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
|
||||
return resp
|
||||
}
|
||||
|
||||
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse {
|
||||
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
|
||||
await withCheckedContinuation { cont in
|
||||
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log))
|
||||
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,23 +357,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
|
||||
let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
|
||||
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
|
||||
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
|
||||
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
|
||||
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
|
||||
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
|
||||
}
|
||||
|
||||
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
|
||||
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
|
||||
return await processSendMessageCmd(toChatType: type, cmd: cmd)
|
||||
}
|
||||
|
||||
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? {
|
||||
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
|
||||
let chatModel = ChatModel.shared
|
||||
let r: ChatResponse
|
||||
if toChatType == .direct {
|
||||
@@ -386,13 +380,10 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
|
||||
}
|
||||
})
|
||||
r = await chatSendCmd(cmd, bgTask: false)
|
||||
if case let .newChatItems(_, aChatItems) = r {
|
||||
let cItems = aChatItems.map { $0.chatItem }
|
||||
if let cItemLast = cItems.last {
|
||||
cItem = cItemLast
|
||||
chatModel.messageDelivery[cItemLast.id] = endTask
|
||||
}
|
||||
return cItems
|
||||
if case let .newChatItem(_, aChatItem) = r {
|
||||
cItem = aChatItem.chatItem
|
||||
chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
|
||||
return cItem
|
||||
}
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
AlertManager.shared.showAlert(networkErrorAlert)
|
||||
@@ -403,18 +394,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
|
||||
return nil
|
||||
} else {
|
||||
r = await chatSendCmd(cmd, bgDelay: msgDelay)
|
||||
if case let .newChatItems(_, aChatItems) = r {
|
||||
return aChatItems.map { $0.chatItem }
|
||||
if case let .newChatItem(_, aChatItem) = r {
|
||||
return aChatItem.chatItem
|
||||
}
|
||||
sendMessageErrorAlert(r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
|
||||
let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
|
||||
if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
|
||||
createChatItemsErrorAlert(r)
|
||||
func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
|
||||
let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
|
||||
if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
|
||||
createChatItemErrorAlert(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -426,8 +417,8 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
||||
)
|
||||
}
|
||||
|
||||
private func createChatItemsErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("apiCreateChatItems error: \(String(describing: r))")
|
||||
private func createChatItemErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("apiCreateChatItem error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error creating message",
|
||||
message: "Error: \(responseError(r))"
|
||||
@@ -462,7 +453,7 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String
|
||||
let r = chatSendCmdSync(.apiGetNtfToken)
|
||||
switch r {
|
||||
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
|
||||
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil)
|
||||
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
|
||||
default:
|
||||
logger.debug("apiGetNtfToken response: \(String(describing: r))")
|
||||
return (nil, nil, .off, nil)
|
||||
@@ -585,25 +576,19 @@ func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profi
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
|
||||
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, ConnectionStats?) {
|
||||
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
|
||||
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
|
||||
let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
|
||||
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
|
||||
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
|
||||
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
|
||||
throw r
|
||||
@@ -651,8 +636,8 @@ func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, String) {
|
||||
let r = await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
|
||||
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
|
||||
}
|
||||
@@ -688,13 +673,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
|
||||
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
|
||||
|
||||
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
|
||||
let userId = try currentUserId("apiConnectPlan")
|
||||
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
|
||||
@@ -743,12 +721,6 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
||||
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
|
||||
)
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))):
|
||||
let alert = mkAlert(
|
||||
title: "Undelivered messages",
|
||||
message: "The connection reached the limit of undelivered messages, your contact may be offline."
|
||||
)
|
||||
return (nil, alert)
|
||||
case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
|
||||
if internalErr == "SEUniqueID" {
|
||||
let alert = mkAlert(
|
||||
@@ -1018,10 +990,6 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
|
||||
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
}
|
||||
|
||||
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
|
||||
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
|
||||
}
|
||||
|
||||
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
|
||||
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
|
||||
}
|
||||
@@ -1057,122 +1025,77 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
|
||||
}
|
||||
|
||||
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
|
||||
await receiveFiles(
|
||||
user: user,
|
||||
fileIds: [fileId],
|
||||
userApprovedRelays: userApprovedRelays,
|
||||
if let chatItem = await apiReceiveFile(
|
||||
fileId: fileId,
|
||||
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
|
||||
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
|
||||
auto: auto
|
||||
)
|
||||
) {
|
||||
await chatItemSimpleUpdate(user, chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
|
||||
var fileIdsToApprove = [Int64]()
|
||||
var srvsToApprove = Set<String>()
|
||||
var otherFileErrs = [ChatResponse]()
|
||||
|
||||
for fileId in fileIds {
|
||||
let r = await chatSendCmd(
|
||||
.receiveFile(
|
||||
fileId: fileId,
|
||||
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
|
||||
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
|
||||
inline: nil
|
||||
func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
|
||||
let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
|
||||
let am = AlertManager.shared
|
||||
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
|
||||
if case .rcvFileAcceptedSndCancelled = r {
|
||||
logger.debug("apiReceiveFile error: sender cancelled file transfer")
|
||||
if !auto {
|
||||
am.showAlertMsg(
|
||||
title: "Cannot receive file",
|
||||
message: "Sender cancelled file transfer."
|
||||
)
|
||||
)
|
||||
switch r {
|
||||
case let .rcvFileAccepted(_, chatItem):
|
||||
await chatItemSimpleUpdate(user, chatItem)
|
||||
default:
|
||||
if let chatError = chatError(r) {
|
||||
switch chatError {
|
||||
case let .fileNotApproved(fileId, unknownServers):
|
||||
fileIdsToApprove.append(fileId)
|
||||
srvsToApprove.formUnion(unknownServers)
|
||||
default:
|
||||
otherFileErrs.append(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !auto {
|
||||
let otherErrsStr = if otherFileErrs.isEmpty {
|
||||
""
|
||||
} else if otherFileErrs.count == 1 {
|
||||
"\(otherFileErrs[0])"
|
||||
} else if otherFileErrs.count == 2 {
|
||||
"\(otherFileErrs[0])\n\(otherFileErrs[1])"
|
||||
} else {
|
||||
"\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)"
|
||||
} else if let networkErrorAlert = networkErrorAlert(r) {
|
||||
logger.error("apiReceiveFile network error: \(String(describing: r))")
|
||||
if !auto {
|
||||
am.showAlert(networkErrorAlert)
|
||||
}
|
||||
|
||||
// If there are not approved files, alert is shown the same way both in case of singular and plural files reception
|
||||
if !fileIdsToApprove.isEmpty {
|
||||
let srvs = srvsToApprove
|
||||
.map { s in
|
||||
} else {
|
||||
switch chatError(r) {
|
||||
case .fileCancelled:
|
||||
logger.debug("apiReceiveFile ignoring fileCancelled error")
|
||||
case .fileAlreadyReceiving:
|
||||
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
|
||||
case let .fileNotApproved(fileId, unknownServers):
|
||||
logger.debug("apiReceiveFile fileNotApproved error")
|
||||
if !auto {
|
||||
let srvs = unknownServers.map { s in
|
||||
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
|
||||
srv.hostnames[0]
|
||||
} else {
|
||||
serverHost(s)
|
||||
}
|
||||
}
|
||||
.sorted()
|
||||
.joined(separator: ", ")
|
||||
let fIds = fileIdsToApprove
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Unknown servers!", comment: "alert title"),
|
||||
message: (
|
||||
String.localizedStringWithFormat(NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: %@.", comment: "alert message"), srvs) +
|
||||
(otherErrsStr != "" ? "\n\n" + String.localizedStringWithFormat(NSLocalizedString("Other file errors:\n%@", comment: "alert message"), otherErrsStr) : "")
|
||||
),
|
||||
buttonTitle: NSLocalizedString("Download", comment: "alert button"),
|
||||
buttonAction: {
|
||||
Task {
|
||||
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFiles(user: user, fileIds: fIds, userApprovedRelays: true)
|
||||
am.showAlert(Alert(
|
||||
title: Text("Unknown servers!"),
|
||||
message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
|
||||
primaryButton: .default(
|
||||
Text("Download"),
|
||||
action: {
|
||||
Task {
|
||||
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelButton: true
|
||||
)
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
} else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it
|
||||
let errorResponse = otherFileErrs.first!
|
||||
switch errorResponse {
|
||||
case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer):
|
||||
logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Cannot receive file", comment: "alert title"),
|
||||
message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message")
|
||||
)
|
||||
}
|
||||
default:
|
||||
if let chatError = chatError(errorResponse) {
|
||||
switch chatError {
|
||||
case .fileCancelled, .fileAlreadyReceiving:
|
||||
logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
|
||||
default:
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error receiving file", comment: "alert title"),
|
||||
message: responseError(errorResponse)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error receiving file", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("File errors:\n%@", comment: "alert message"), otherErrsStr)
|
||||
default:
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
if !auto {
|
||||
am.showAlertMsg(
|
||||
title: "Error receiving file",
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancelFile(user: User, fileId: Int64) async {
|
||||
@@ -1295,18 +1218,12 @@ func apiEndCall(_ contact: Contact) async throws {
|
||||
try await sendCommandOkResp(.apiEndCall(contact: contact))
|
||||
}
|
||||
|
||||
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
|
||||
func apiGetCallInvitations() throws -> [RcvCallInvitation] {
|
||||
let r = chatSendCmdSync(.apiGetCallInvitations)
|
||||
if case let .callInvitations(invs) = r { return invs }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
|
||||
let r = await chatSendCmd(.apiGetCallInvitations)
|
||||
if case let .callInvitations(invs) = r { return invs }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
|
||||
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
|
||||
@@ -1354,23 +1271,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
|
||||
|
||||
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.markChatItemsRead(cInfo, [cItem.id])
|
||||
}
|
||||
await ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
} catch {
|
||||
logger.error("apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
|
||||
do {
|
||||
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.markChatItemsRead(cInfo, itemIds)
|
||||
}
|
||||
} catch {
|
||||
logger.error("apiChatItemsRead error: \(responseError(error))")
|
||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1515,9 +1420,9 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
||||
throw r
|
||||
}
|
||||
|
||||
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
|
||||
func getAgentSubsTotal() throws -> (SMPServerSubs, Bool) {
|
||||
let userId = try currentUserId("getAgentSubsTotal")
|
||||
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
|
||||
let r = chatSendCmdSync(.getAgentSubsTotal(userId: userId), log: false)
|
||||
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
|
||||
logger.error("getAgentSubsTotal error: \(String(describing: r))")
|
||||
throw r
|
||||
@@ -1612,7 +1517,7 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
try getUserChatData()
|
||||
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
||||
if (refreshInvitations) {
|
||||
Task { try await refreshCallInvitations() }
|
||||
try refreshCallInvitations()
|
||||
}
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
_ = try apiStartChat()
|
||||
@@ -1686,7 +1591,8 @@ func getUserChatData() throws {
|
||||
m.userAddress = try apiGetUserAddress()
|
||||
m.chatItemTTL = try getChatItemTTL()
|
||||
let chats = try apiGetChats()
|
||||
m.updateChats(chats)
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
m.popChatCollector.clear()
|
||||
}
|
||||
|
||||
private func getUserChatDataAsync() async throws {
|
||||
@@ -1698,12 +1604,14 @@ private func getUserChatDataAsync() async throws {
|
||||
await MainActor.run {
|
||||
m.userAddress = userAddress
|
||||
m.chatItemTTL = chatItemTTL
|
||||
m.updateChats(chats)
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
m.popChatCollector.clear()
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
m.userAddress = nil
|
||||
m.updateChats([])
|
||||
m.chats = []
|
||||
m.popChatCollector.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1864,45 +1772,36 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
n.networkStatuses = ns
|
||||
}
|
||||
}
|
||||
case let .newChatItems(user, chatItems):
|
||||
for chatItem in chatItems {
|
||||
let cInfo = chatItem.chatInfo
|
||||
let cItem = chatItem.chatItem
|
||||
await MainActor.run {
|
||||
if active(user) {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||
m.increaseUnreadCounter(user: user)
|
||||
}
|
||||
}
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId, auto: true)
|
||||
}
|
||||
}
|
||||
if cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
case let .newChatItem(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
await MainActor.run {
|
||||
if active(user) {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||
m.increaseUnreadCounter(user: user)
|
||||
}
|
||||
}
|
||||
case let .chatItemsStatusesUpdated(user, chatItems):
|
||||
for chatItem in chatItems {
|
||||
let cInfo = chatItem.chatInfo
|
||||
let cItem = chatItem.chatItem
|
||||
if !cItem.isDeletedContent && active(user) {
|
||||
await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId, auto: true)
|
||||
}
|
||||
if let endTask = m.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
case .sndNew: ()
|
||||
case .sndSent: endTask()
|
||||
case .sndRcvd: endTask()
|
||||
case .sndErrorAuth: endTask()
|
||||
case .sndError: endTask()
|
||||
case .sndWarning: endTask()
|
||||
case .rcvNew: ()
|
||||
case .rcvRead: ()
|
||||
case .invalid: ()
|
||||
}
|
||||
}
|
||||
if cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
case let .chatItemStatusUpdated(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if !cItem.isDeletedContent && active(user) {
|
||||
await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
|
||||
}
|
||||
if let endTask = m.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
case .sndSent: endTask()
|
||||
case .sndErrorAuth: endTask()
|
||||
case .sndError: endTask()
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
case let .chatItemUpdated(user, aChatItem):
|
||||
@@ -2096,6 +1995,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
await withCall(contact) { call in
|
||||
await MainActor.run {
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
}
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
@@ -2264,42 +2164,32 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCallInvitations() async throws {
|
||||
func refreshCallInvitations() throws {
|
||||
let m = ChatModel.shared
|
||||
let callInvitations = try await apiGetCallInvitations()
|
||||
await MainActor.run {
|
||||
m.callInvitations = callsByChat(callInvitations)
|
||||
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||
m.ntfCallInvitationAction = nil
|
||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||
activateCall(invitation)
|
||||
}
|
||||
let callInvitations = try justRefreshCallInvitations()
|
||||
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||
m.ntfCallInvitationAction = nil
|
||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||
activateCall(invitation)
|
||||
}
|
||||
}
|
||||
|
||||
func justRefreshCallInvitations() async throws {
|
||||
let callInvitations = try apiGetCallInvitationsSync()
|
||||
await MainActor.run {
|
||||
ChatModel.shared.callInvitations = callsByChat(callInvitations)
|
||||
}
|
||||
}
|
||||
|
||||
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
|
||||
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
|
||||
result, inv in result[inv.contact.id] = inv
|
||||
}
|
||||
func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
|
||||
let m = ChatModel.shared
|
||||
let callInvitations = try apiGetCallInvitations()
|
||||
m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
|
||||
return callInvitations
|
||||
}
|
||||
|
||||
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||
if !callInvitation.user.showNotifications { return }
|
||||
let m = ChatModel.shared
|
||||
logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
|
||||
if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
|
||||
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
m.callInvitations[callInvitation.contact.id]?.callUUID = nil
|
||||
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
|
||||
}
|
||||
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
||||
} else {
|
||||
|
||||
@@ -82,18 +82,10 @@ struct SimpleXApp: App {
|
||||
|
||||
if appState != .stopped {
|
||||
startChatAndActivate {
|
||||
if chatModel.chatRunning == true {
|
||||
if let ntfResponse = chatModel.notificationResponse {
|
||||
chatModel.notificationResponse = nil
|
||||
NtfManager.shared.processNotificationResponse(ntfResponse)
|
||||
}
|
||||
if appState.inactive {
|
||||
Task {
|
||||
await updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
await updateCallInvitations()
|
||||
}
|
||||
}
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
updateCallInvitations()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,16 +130,16 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChats() async {
|
||||
private func updateChats() {
|
||||
do {
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run { chatModel.updateChats(chats) }
|
||||
let chats = try apiGetChats()
|
||||
chatModel.updateChats(with: chats)
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id) {
|
||||
Task { await loadChat(chat: chat, clearItems: false) }
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
await MainActor.run { chatModel.ntfContactRequest = nil }
|
||||
chatModel.ntfContactRequest = nil
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
||||
}
|
||||
@@ -157,9 +149,9 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCallInvitations() async {
|
||||
private func updateCallInvitations() {
|
||||
do {
|
||||
try await refreshCallInvitations()
|
||||
try refreshCallInvitations()
|
||||
} catch let error {
|
||||
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ var systemInDarkThemeCurrently: Bool {
|
||||
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||
}
|
||||
|
||||
func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
|
||||
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
|
||||
func reactOnDarkThemeChanges() {
|
||||
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
|
||||
// Change active colors from light to dark and back based on system theme
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ extension ThemeWallpaper {
|
||||
public func importFromString() -> ThemeWallpaper {
|
||||
if preset == nil, let image {
|
||||
// Need to save image from string and to save its path
|
||||
if let parsed = imageFromBase64(image),
|
||||
if let parsed = UIImage(base64Encoded: image),
|
||||
let filename = saveWallpaperFile(image: parsed) {
|
||||
var copy = self
|
||||
copy.image = nil
|
||||
@@ -122,7 +122,7 @@ extension ThemeWallpaper {
|
||||
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
|
||||
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
|
||||
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
|
||||
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSizeSync(image, maxDataSize: 5_000_000) } else { nil }
|
||||
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
|
||||
return ThemeWallpaper (
|
||||
preset: preset,
|
||||
scale: scale,
|
||||
|
||||
@@ -53,7 +53,7 @@ class ThemeManager {
|
||||
return perUserTheme
|
||||
}
|
||||
let defaultTheme = defaultActiveTheme(appSettingsTheme)
|
||||
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
|
||||
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
|
||||
}
|
||||
|
||||
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
|
||||
@@ -197,7 +197,7 @@ class ThemeManager {
|
||||
var themeIds = currentThemeIdsDefault.get()
|
||||
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||
currentThemeIdsDefault.set(themeIds)
|
||||
applyTheme(currentThemeDefault.get())
|
||||
applyTheme(nonSystemThemeName)
|
||||
}
|
||||
|
||||
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
|
||||
|
||||
@@ -17,8 +17,8 @@ struct ActiveCallView: View {
|
||||
@ObservedObject var call: Call
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State private var client: WebRTCClient? = nil
|
||||
@State private var activeCall: WebRTCClient.Call? = nil
|
||||
@State private var localRendererAspectRatio: CGFloat? = nil
|
||||
@State var remoteContentMode: UIView.ContentMode = .scaleAspectFill
|
||||
@Binding var canConnectCall: Bool
|
||||
@State var prevColorScheme: ColorScheme = .dark
|
||||
@State var pipShown = false
|
||||
@@ -27,39 +27,24 @@ struct ActiveCallView: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ZStack(alignment: .bottom) {
|
||||
if let client = client, call.hasVideo {
|
||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
||||
GeometryReader { g in
|
||||
let width = g.size.width * 0.3
|
||||
ZStack(alignment: .topTrailing) {
|
||||
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
|
||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
||||
.cornerRadius(10)
|
||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
||||
.padding([.top, .trailing], 17)
|
||||
ZStack(alignment: .center) {
|
||||
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.primary.opacity(0.000001))
|
||||
|
||||
CallViewRemote(client: client, call: call, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, contentMode: $remoteContentMode, pipShown: $pipShown)
|
||||
.onTapGesture {
|
||||
remoteContentMode = remoteContentMode == .scaleAspectFill ? .scaleAspectFit : .scaleAspectFill
|
||||
}
|
||||
|
||||
Group {
|
||||
let localVideoTrack = client.activeCall?.localVideoTrack ?? client.notConnectedCall?.localCameraAndTrack?.1
|
||||
if localVideoTrack != nil {
|
||||
CallViewLocal(client: client, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
||||
.onDisappear {
|
||||
localRendererAspectRatio = nil
|
||||
}
|
||||
} else {
|
||||
Rectangle().fill(.black)
|
||||
}
|
||||
}
|
||||
.cornerRadius(10)
|
||||
.frame(width: width, height: localRendererAspectRatio == nil ? (g.size.width < g.size.height ? width * 1.33 : width / 1.33) : width / (localRendererAspectRatio ?? 1))
|
||||
.padding([.top, .trailing], 17)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let call = m.activeCall, let client = client, (!pipShown || !call.hasVideo) {
|
||||
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
|
||||
ActiveCallOverlay(call: call, client: client)
|
||||
}
|
||||
}
|
||||
@@ -69,9 +54,6 @@ struct ActiveCallView: View {
|
||||
.onAppear {
|
||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
||||
AppDelegate.keepScreenOn(true)
|
||||
Task {
|
||||
await askRequiredPermissions()
|
||||
}
|
||||
createWebRTCClient()
|
||||
dismissAllSheets()
|
||||
hideKeyboard()
|
||||
@@ -102,7 +84,7 @@ struct ActiveCallView: View {
|
||||
|
||||
private func createWebRTCClient() {
|
||||
if client == nil && canConnectCall {
|
||||
client = WebRTCClient({ msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
||||
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
||||
Task {
|
||||
await m.callCommand.setClient(client)
|
||||
}
|
||||
@@ -117,7 +99,7 @@ struct ActiveCallView: View {
|
||||
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
||||
switch msg.resp {
|
||||
case let .capabilities(capabilities):
|
||||
let callType = CallType(media: call.initialCallType, capabilities: capabilities)
|
||||
let callType = CallType(media: call.localMedia, capabilities: capabilities)
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallInvitation(call.contact, callType)
|
||||
@@ -128,7 +110,7 @@ struct ActiveCallView: View {
|
||||
call.callState = .invitationSent
|
||||
call.localCapabilities = capabilities
|
||||
}
|
||||
if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
|
||||
if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||
}
|
||||
CallSoundsPlayer.shared.startConnectingCallSound()
|
||||
@@ -138,7 +120,7 @@ struct ActiveCallView: View {
|
||||
Task {
|
||||
do {
|
||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||
media: call.initialCallType, capabilities: capabilities)
|
||||
media: call.localMedia, capabilities: capabilities)
|
||||
} catch {
|
||||
logger.error("apiSendCallOffer \(responseError(error))")
|
||||
}
|
||||
@@ -182,9 +164,6 @@ struct ActiveCallView: View {
|
||||
}
|
||||
if state.connectionState == "closed" {
|
||||
closeCallView(client)
|
||||
if let callUUID = m.activeCall?.callUUID {
|
||||
CallController.shared.endCall(callUUID: callUUID)
|
||||
}
|
||||
m.activeCall = nil
|
||||
m.activeCallViewIsCollapsed = false
|
||||
}
|
||||
@@ -203,18 +182,10 @@ struct ActiveCallView: View {
|
||||
CallSoundsPlayer.shared.vibrate(long: false)
|
||||
wasConnected = true
|
||||
}
|
||||
case let .peerMedia(source, enabled):
|
||||
switch source {
|
||||
case .mic: call.peerMediaSources.mic = enabled
|
||||
case .camera: call.peerMediaSources.camera = enabled
|
||||
case .screenAudio: call.peerMediaSources.screenAudio = enabled
|
||||
case .screenVideo: call.peerMediaSources.screenVideo = enabled
|
||||
case .unknown: ()
|
||||
}
|
||||
case .ended:
|
||||
closeCallView(client)
|
||||
call.callState = .ended
|
||||
if let uuid = call.callUUID {
|
||||
if let uuid = call.callkitUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
}
|
||||
case .ok:
|
||||
@@ -243,38 +214,16 @@ struct ActiveCallView: View {
|
||||
ChatReceiver.shared.messagesChannel = nil
|
||||
return
|
||||
}
|
||||
if case let .chatItemsStatusesUpdated(_, chatItems) = msg,
|
||||
chatItems.contains(where: { ci in
|
||||
ci.chatInfo.id == call.contact.id &&
|
||||
ci.chatItem.content.isSndCall &&
|
||||
ci.chatItem.meta.itemStatus.isSndRcvd
|
||||
}) {
|
||||
if case let .chatItemStatusUpdated(_, msg) = msg,
|
||||
msg.chatInfo.id == call.contact.id,
|
||||
case .sndCall = msg.chatItem.content,
|
||||
case .sndRcvd = msg.chatItem.meta.itemStatus {
|
||||
CallSoundsPlayer.shared.startInCallSound()
|
||||
ChatReceiver.shared.messagesChannel = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func askRequiredPermissions() async {
|
||||
let mic = await WebRTCClient.isAuthorized(for: .audio)
|
||||
await MainActor.run {
|
||||
call.localMediaSources.mic = mic
|
||||
}
|
||||
let cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
var camera = call.initialCallType == .audio || cameraAuthorized
|
||||
if call.initialCallType == .video && !cameraAuthorized {
|
||||
camera = await WebRTCClient.isAuthorized(for: .video)
|
||||
await MainActor.run {
|
||||
if camera, let client {
|
||||
client.setCameraEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !mic || !camera {
|
||||
WebRTCClient.showUnauthorizedAlert(for: !mic ? .audio : .video)
|
||||
}
|
||||
}
|
||||
|
||||
private func closeCallView(_ client: WebRTCClient) {
|
||||
if m.activeCall != nil {
|
||||
m.showCallView = false
|
||||
@@ -290,16 +239,44 @@ struct ActiveCallOverlay: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
switch call.hasVideo {
|
||||
case true:
|
||||
switch call.localMedia {
|
||||
case .video:
|
||||
videoCallInfoView(call)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding(.horizontal)
|
||||
// Fixed vertical padding required for preserving position of buttons row when changing audio-to-video and back in landscape orientation.
|
||||
// Otherwise, bigger padding is added by SwiftUI when switching call types
|
||||
.padding(.vertical, 10)
|
||||
case false:
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
toggleAudioButton()
|
||||
Spacer()
|
||||
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
|
||||
toggleSpeakerButton()
|
||||
.frame(width: 40, height: 40)
|
||||
} else if call.hasMedia {
|
||||
AudioDevicePicker()
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: 40, maxHeight: 40)
|
||||
} else {
|
||||
Color.clear.frame(width: 40, height: 40)
|
||||
}
|
||||
Spacer()
|
||||
endCallButton()
|
||||
Spacer()
|
||||
if call.videoEnabled {
|
||||
flipCameraButton()
|
||||
} else {
|
||||
Color.clear.frame(width: 40, height: 40)
|
||||
}
|
||||
Spacer()
|
||||
toggleVideoButton()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
case .audio:
|
||||
ZStack(alignment: .topLeading) {
|
||||
Button {
|
||||
chatModel.activeCallViewIsCollapsed = true
|
||||
@@ -314,32 +291,35 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.8)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
toggleMicButton()
|
||||
Spacer()
|
||||
audioDeviceButton()
|
||||
Spacer()
|
||||
endCallButton()
|
||||
Spacer()
|
||||
if call.localMediaSources.camera {
|
||||
flipCameraButton()
|
||||
} else {
|
||||
Color.clear.frame(width: 60, height: 60)
|
||||
ZStack(alignment: .bottom) {
|
||||
toggleAudioButton()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
endCallButton()
|
||||
// Check if the only input is microphone. And in this case show toggle button,
|
||||
// If there are more inputs, it probably means something like bluetooth headphones are available
|
||||
// and in this case show iOS button for choosing different output.
|
||||
// There is no way to get available outputs, only inputs
|
||||
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
|
||||
toggleSpeakerButton()
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
} else if call.hasMedia {
|
||||
AudioDevicePicker()
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: 50, maxHeight: 40)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
} else {
|
||||
Color.clear.frame(width: 50, height: 40)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
toggleCameraButton()
|
||||
.padding(.bottom, 60)
|
||||
.padding(.horizontal, 48)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
.frame(maxWidth: 440, alignment: .center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.onAppear {
|
||||
@@ -401,54 +381,36 @@ struct ActiveCallOverlay: View {
|
||||
|
||||
private func endCallButton() -> some View {
|
||||
let cc = CallController.shared
|
||||
return callButton("phone.down.fill", .red, padding: 10) {
|
||||
if let uuid = call.callUUID {
|
||||
return callButton("phone.down.fill", width: 60, height: 60) {
|
||||
if let uuid = call.callkitUUID {
|
||||
cc.endCall(callUUID: uuid)
|
||||
} else {
|
||||
cc.endCall(call: call) {}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
private func toggleMicButton() -> some View {
|
||||
controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) {
|
||||
private func toggleAudioButton() -> some View {
|
||||
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
|
||||
Task {
|
||||
if await WebRTCClient.isAuthorized(for: .audio) {
|
||||
client.setAudioEnabled(!call.localMediaSources.mic)
|
||||
} else { WebRTCClient.showUnauthorizedAlert(for: .audio) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audioDeviceButton() -> some View {
|
||||
// Check if the only input is microphone. And in this case show toggle button,
|
||||
// If there are more inputs, it probably means something like bluetooth headphones are available
|
||||
// and in this case show iOS button for choosing different output.
|
||||
// There is no way to get available outputs, only inputs
|
||||
Group {
|
||||
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
|
||||
toggleSpeakerButton()
|
||||
} else {
|
||||
audioDevicePickerButton()
|
||||
}
|
||||
}
|
||||
.onChange(of: call.localMediaSources.hasVideo) { hasVideo in
|
||||
let current = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType
|
||||
let speakerEnabled = current == .builtInSpeaker
|
||||
let receiverEnabled = current == .builtInReceiver
|
||||
// react automatically only when receiver were selected, otherwise keep an external device selected
|
||||
if !speakerEnabled && hasVideo && receiverEnabled {
|
||||
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled, skipExternalDevice: true)
|
||||
call.speakerEnabled = !speakerEnabled
|
||||
client.setAudioEnabled(!call.audioEnabled)
|
||||
DispatchQueue.main.async {
|
||||
call.audioEnabled = !call.audioEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSpeakerButton() -> some View {
|
||||
controlButton(call, !call.peerMediaSources.mic ? "speaker.slash" : call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill", padding: !call.peerMediaSources.mic ? 16 : call.speakerEnabled ? 15 : 17) {
|
||||
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
|
||||
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
|
||||
call.speakerEnabled = !speakerEnabled
|
||||
controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
|
||||
Task {
|
||||
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
|
||||
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
|
||||
DispatchQueue.main.async {
|
||||
call.speakerEnabled = !speakerEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
deviceManager.call = call
|
||||
@@ -456,67 +418,53 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleCameraButton() -> some View {
|
||||
controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) {
|
||||
private func toggleVideoButton() -> some View {
|
||||
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
|
||||
Task {
|
||||
if await WebRTCClient.isAuthorized(for: .video) {
|
||||
client.setCameraEnabled(!call.localMediaSources.camera)
|
||||
} else { WebRTCClient.showUnauthorizedAlert(for: .video) }
|
||||
client.setVideoEnabled(!call.videoEnabled)
|
||||
DispatchQueue.main.async {
|
||||
call.videoEnabled = !call.videoEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
|
||||
}
|
||||
|
||||
@ViewBuilder private func flipCameraButton() -> some View {
|
||||
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
|
||||
controlButton(call, "arrow.triangle.2.circlepath") {
|
||||
Task {
|
||||
if await WebRTCClient.isAuthorized(for: .video) {
|
||||
client.flipCamera()
|
||||
}
|
||||
client.flipCamera()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
|
||||
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
|
||||
if call.hasMedia {
|
||||
callButton(imageName, width: 50, height: 38, perform)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.85)
|
||||
} else {
|
||||
Color.clear.frame(width: 50, height: 38)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func audioDevicePickerButton() -> some View {
|
||||
AudioDevicePicker()
|
||||
.opacity(0.8)
|
||||
.scaleEffect(2)
|
||||
.padding(10)
|
||||
.frame(width: 60, height: 60)
|
||||
.background(call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2))
|
||||
.clipShape(.circle)
|
||||
}
|
||||
|
||||
private func callButton(_ imageName: String, _ background: Color, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||
private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||
Button {
|
||||
perform()
|
||||
} label: {
|
||||
Image(systemName: imageName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(padding)
|
||||
.frame(width: 60, height: 60)
|
||||
.background(background)
|
||||
.frame(maxWidth: width, maxHeight: height)
|
||||
}
|
||||
.foregroundColor(whiteColorWithAlpha)
|
||||
.clipShape(.circle)
|
||||
}
|
||||
|
||||
private var whiteColorWithAlpha: Color {
|
||||
get { Color(red: 204 / 255, green: 204 / 255, blue: 204 / 255) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveCallOverlay_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil)))
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||
.background(.black)
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil)))
|
||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||
.background(.black)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
logger.debug("CallController.provider CXStartCallAction")
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID) {
|
||||
action.fulfill()
|
||||
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
||||
} else {
|
||||
@@ -61,30 +61,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
logger.debug("CallController.provider CXAnswerCallAction")
|
||||
Task {
|
||||
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
|
||||
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
|
||||
if !chatIsReady {
|
||||
action.fail()
|
||||
return
|
||||
}
|
||||
if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
|
||||
try? await justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
}
|
||||
await MainActor.run {
|
||||
logger.debug("CallController.provider will answer on call")
|
||||
|
||||
if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
logger.debug("CallController.provider answered on call")
|
||||
// WebRTC call should be in connected state to fulfill.
|
||||
// Otherwise no audio and mic working on lockscreen
|
||||
fulfillOnConnect = action
|
||||
} else {
|
||||
logger.debug("CallController.provider will fail the call")
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
if callManager.answerIncomingCall(callUUID: action.callUUID) {
|
||||
// WebRTC call should be in connected state to fulfill.
|
||||
// Otherwise no audio and mic working on lockscreen
|
||||
fulfillOnConnect = action
|
||||
} else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +75,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
// Should be nil here if connection was in connected state
|
||||
fulfillOnConnect?.fail()
|
||||
fulfillOnConnect = nil
|
||||
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
|
||||
callManager.endCall(callUUID: action.callUUID) { ok in
|
||||
if ok {
|
||||
action.fulfill()
|
||||
} else {
|
||||
@@ -104,7 +86,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
action.fail()
|
||||
@@ -121,8 +103,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
||||
do {
|
||||
let hasVideo = ChatModel.shared.activeCall?.hasVideo == true
|
||||
if hasVideo {
|
||||
let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true
|
||||
if supportsVideo {
|
||||
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||
} else {
|
||||
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||
@@ -133,7 +115,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
|
||||
if let preferred = audioSession.preferredInputDevice() {
|
||||
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
|
||||
} else if hasVideo {
|
||||
} else if supportsVideo {
|
||||
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
|
||||
}
|
||||
}
|
||||
@@ -174,19 +156,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
|
||||
logger.debug("CallController waiting until chat started")
|
||||
var t: UInt64 = 0
|
||||
repeat {
|
||||
if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
|
||||
return true
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||
t += stepMs
|
||||
} while t < timeoutMs
|
||||
return false
|
||||
}
|
||||
|
||||
@objc(pushRegistry:didUpdatePushCredentials:forType:)
|
||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
||||
@@ -202,19 +171,32 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
return
|
||||
}
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
logger.debug("CallController: initializing chat")
|
||||
do {
|
||||
try initializeChat(start: true, refreshInvitations: false)
|
||||
} catch let error {
|
||||
logger.error("CallController: initializing chat error: \(error)")
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.debug("CallController: initialized chat")
|
||||
startChatForCall()
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
// There are no invitations in the model, as it was processed by NSE
|
||||
_ = try? justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||
// Extract the call information from the push notification payload
|
||||
let m = ChatModel.shared
|
||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||
let displayName = payload.dictionaryPayload["displayName"] as? String,
|
||||
let callUUID = payload.dictionaryPayload["callUUID"] as? String,
|
||||
let uuid = UUID(uuidString: callUUID),
|
||||
let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
|
||||
let mediaStr = payload.dictionaryPayload["media"] as? String,
|
||||
let media = CallMediaType(rawValue: mediaStr) {
|
||||
let update = self.cxCallUpdate(contactId, displayName, media)
|
||||
let callTs = Date(timeIntervalSince1970: callTsInterval)
|
||||
if callTs.timeIntervalSinceNow >= -180 {
|
||||
let invitation = m.callInvitations[contactId] {
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
if let uuid = invitation.callkitUUID {
|
||||
logger.debug("CallController: report pushkit call via CallKit")
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||
if error != nil {
|
||||
m.callInvitations.removeValue(forKey: contactId)
|
||||
@@ -223,31 +205,11 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
completion()
|
||||
}
|
||||
} else {
|
||||
logger.debug("CallController will expire call 1")
|
||||
self.reportExpiredCall(update: update, completion)
|
||||
}
|
||||
} else {
|
||||
logger.debug("CallController will expire call 2")
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
}
|
||||
|
||||
//DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
logger.debug("CallController: initializing chat")
|
||||
do {
|
||||
try initializeChat(start: true, refreshInvitations: false)
|
||||
} catch let error {
|
||||
logger.error("CallController: initializing chat error: \(error)")
|
||||
if let call = ChatModel.shared.activeCall {
|
||||
self.endCall(call: call, completed: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.debug("CallController: initialized chat")
|
||||
startChatForCall()
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
}
|
||||
|
||||
// This function fulfils the requirement to always report a call when PushKit notification is received,
|
||||
@@ -277,8 +239,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
|
||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
|
||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
@@ -299,14 +261,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
return update
|
||||
}
|
||||
|
||||
private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
|
||||
let update = CXCallUpdate()
|
||||
update.remoteHandle = CXHandle(type: .generic, value: contactId)
|
||||
update.hasVideo = media == .video
|
||||
update.localizedCallerName = displayName
|
||||
return update
|
||||
}
|
||||
|
||||
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting incoming call connected")
|
||||
if CallController.useCallKit() {
|
||||
@@ -318,14 +272,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting outgoing call connected")
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
||||
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||
logger.debug("CallController: reporting remote ended")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
||||
activeCallInvitation = nil
|
||||
@@ -334,17 +288,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func reportCallRemoteEnded(call: Call) {
|
||||
logger.debug("CallController: reporting remote ended")
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||
}
|
||||
}
|
||||
|
||||
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
||||
logger.debug("CallController.startCall")
|
||||
let callUUID = callManager.newOutgoingCall(contact, media)
|
||||
guard let uuid = UUID(uuidString: callUUID) else {
|
||||
return
|
||||
}
|
||||
let uuid = callManager.newOutgoingCall(contact, media)
|
||||
if CallController.useCallKit() {
|
||||
let handle = CXHandle(type: .generic, value: contact.id)
|
||||
let action = CXStartCallAction(call: uuid, handle: handle)
|
||||
@@ -356,17 +307,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
update.localizedCallerName = contact.displayName
|
||||
self.provider.reportCall(with: uuid, updated: update)
|
||||
}
|
||||
} else if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||
logger.debug("CallController.startCall: call started")
|
||||
} else {
|
||||
logger.error("CallController.startCall: no active call")
|
||||
} else if callManager.startOutgoingCall(callUUID: uuid) {
|
||||
if callManager.startOutgoingCall(callUUID: uuid) {
|
||||
logger.debug("CallController.startCall: call started")
|
||||
} else {
|
||||
logger.error("CallController.startCall: no active call")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func answerCall(invitation: RcvCallInvitation) {
|
||||
logger.debug("CallController: answering a call")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
requestTransaction(with: CXAnswerCallAction(call: uuid))
|
||||
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
|
||||
requestTransaction(with: CXAnswerCallAction(call: callUUID))
|
||||
} else {
|
||||
callManager.answerIncomingCall(invitation: invitation)
|
||||
}
|
||||
@@ -375,13 +328,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(callUUID: String) {
|
||||
let uuid = UUID(uuidString: callUUID)
|
||||
logger.debug("CallController: ending the call with UUID \(callUUID)")
|
||||
func endCall(callUUID: UUID) {
|
||||
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
|
||||
if CallController.useCallKit() {
|
||||
if let uuid {
|
||||
requestTransaction(with: CXEndCallAction(call: uuid))
|
||||
}
|
||||
requestTransaction(with: CXEndCallAction(call: callUUID))
|
||||
} else {
|
||||
callManager.endCall(callUUID: callUUID) { ok in
|
||||
if ok {
|
||||
|
||||
@@ -10,25 +10,25 @@ import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
class CallManager {
|
||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
|
||||
let uuid = UUID().uuidString.lowercased()
|
||||
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media)
|
||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
|
||||
let uuid = UUID()
|
||||
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||
call.speakerEnabled = media == .video
|
||||
ChatModel.shared.activeCall = call
|
||||
return uuid
|
||||
}
|
||||
|
||||
func startOutgoingCall(callUUID: String) -> Bool {
|
||||
func startOutgoingCall(callUUID: UUID) -> Bool {
|
||||
let m = ChatModel.shared
|
||||
if let call = m.activeCall, call.callUUID == callUUID {
|
||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||
m.showCallView = true
|
||||
Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) }
|
||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func answerIncomingCall(callUUID: String) -> Bool {
|
||||
func answerIncomingCall(callUUID: UUID) -> Bool {
|
||||
if let invitation = getCallInvitation(callUUID) {
|
||||
answerIncomingCall(invitation: invitation)
|
||||
return true
|
||||
@@ -42,9 +42,9 @@ class CallManager {
|
||||
let call = Call(
|
||||
direction: .incoming,
|
||||
contact: invitation.contact,
|
||||
callUUID: invitation.callUUID,
|
||||
callkitUUID: invitation.callkitUUID,
|
||||
callState: .invitationAccepted,
|
||||
initialCallType: invitation.callType.media,
|
||||
localMedia: invitation.callType.media,
|
||||
sharedKey: invitation.sharedKey
|
||||
)
|
||||
call.speakerEnabled = invitation.callType.media == .video
|
||||
@@ -68,17 +68,17 @@ class CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
func enableMedia(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
let m = ChatModel.shared
|
||||
Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) }
|
||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
|
||||
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
endCall(call: call) { completed(true) }
|
||||
} else if let invitation = getCallInvitation(callUUID) {
|
||||
endCall(invitation: invitation) { completed(true) }
|
||||
@@ -126,8 +126,8 @@ class CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
|
||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
|
||||
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
|
||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
|
||||
return invitation
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -10,49 +10,40 @@ import AVKit
|
||||
|
||||
struct CallViewRemote: UIViewRepresentable {
|
||||
var client: WebRTCClient
|
||||
@ObservedObject var call: Call
|
||||
var activeCall: Binding<WebRTCClient.Call?>
|
||||
@State var enablePip: (Bool) -> Void = {_ in }
|
||||
@Binding var activeCallViewIsCollapsed: Bool
|
||||
@Binding var contentMode: UIView.ContentMode
|
||||
@Binding var pipShown: Bool
|
||||
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
|
||||
self.client = client
|
||||
self.activeCall = activeCall
|
||||
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
|
||||
self._pipShown = pipShown
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
remoteCameraRenderer.videoContentMode = contentMode
|
||||
remoteCameraRenderer.tag = 0
|
||||
if let call = activeCall.wrappedValue {
|
||||
let remoteRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
remoteRenderer.videoContentMode = .scaleAspectFill
|
||||
client.addRemoteRenderer(call, remoteRenderer)
|
||||
addSubviewAndResize(remoteRenderer, into: view)
|
||||
|
||||
let screenVideo = call.peerMediaSources.screenVideo
|
||||
let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
remoteScreenRenderer.videoContentMode = contentMode
|
||||
remoteScreenRenderer.tag = 1
|
||||
remoteScreenRenderer.alpha = screenVideo ? 1 : 0
|
||||
|
||||
context.coordinator.cameraRenderer = remoteCameraRenderer
|
||||
context.coordinator.screenRenderer = remoteScreenRenderer
|
||||
client.addRemoteCameraRenderer(remoteCameraRenderer)
|
||||
client.addRemoteScreenRenderer(remoteScreenRenderer)
|
||||
if screenVideo {
|
||||
addSubviewAndResize(remoteScreenRenderer, remoteCameraRenderer, into: view)
|
||||
} else {
|
||||
addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
|
||||
}
|
||||
|
||||
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||
makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context)
|
||||
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func makeViewWithRTCRenderer(_ remoteCameraRenderer: RTCMTLVideoView, _ remoteScreenRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
|
||||
let pipRemoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
pipRemoteCameraRenderer.videoContentMode = .scaleAspectFill
|
||||
|
||||
let pipRemoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
pipRemoteScreenRenderer.videoContentMode = .scaleAspectFill
|
||||
|
||||
func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
|
||||
let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
|
||||
pipRemoteRenderer.videoContentMode = .scaleAspectFill
|
||||
|
||||
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
|
||||
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
|
||||
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
|
||||
let pipContentSource = AVPictureInPictureController.ContentSource(
|
||||
activeVideoCallSourceView: view,
|
||||
contentViewController: pipVideoCallViewController
|
||||
@@ -64,9 +55,7 @@ struct CallViewRemote: UIViewRepresentable {
|
||||
context.coordinator.pipController = pipController
|
||||
context.coordinator.willShowHide = { show in
|
||||
if show {
|
||||
client.addRemoteCameraRenderer(pipRemoteCameraRenderer)
|
||||
client.addRemoteScreenRenderer(pipRemoteScreenRenderer)
|
||||
context.coordinator.relayout()
|
||||
client.addRemoteRenderer(call, pipRemoteRenderer)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
activeCallViewIsCollapsed = true
|
||||
}
|
||||
@@ -78,29 +67,13 @@ struct CallViewRemote: UIViewRepresentable {
|
||||
}
|
||||
context.coordinator.didShowHide = { show in
|
||||
if show {
|
||||
remoteCameraRenderer.isHidden = true
|
||||
remoteScreenRenderer.isHidden = true
|
||||
remoteRenderer.isHidden = true
|
||||
} else {
|
||||
client.removeRemoteCameraRenderer(pipRemoteCameraRenderer)
|
||||
client.removeRemoteScreenRenderer(pipRemoteScreenRenderer)
|
||||
remoteCameraRenderer.isHidden = false
|
||||
remoteScreenRenderer.isHidden = false
|
||||
client.removeRemoteRenderer(call, pipRemoteRenderer)
|
||||
remoteRenderer.isHidden = false
|
||||
}
|
||||
pipShown = show
|
||||
}
|
||||
context.coordinator.relayout = {
|
||||
let camera = call.peerMediaSources.camera
|
||||
let screenVideo = call.peerMediaSources.screenVideo
|
||||
pipRemoteCameraRenderer.alpha = camera ? 1 : 0
|
||||
pipRemoteScreenRenderer.alpha = screenVideo ? 1 : 0
|
||||
if screenVideo {
|
||||
addSubviewAndResize(pipRemoteScreenRenderer, pipRemoteCameraRenderer, pip: true, into: pipVideoCallViewController.view)
|
||||
} else {
|
||||
addSubviewAndResize(pipRemoteCameraRenderer, pipRemoteScreenRenderer, pip: true, into: pipVideoCallViewController.view)
|
||||
}
|
||||
(pipVideoCallViewController.view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
|
||||
(pipVideoCallViewController.view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
enablePip = { enable in
|
||||
if enable != pipShown /* pipController.isPictureInPictureActive */ {
|
||||
@@ -115,50 +88,24 @@ struct CallViewRemote: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(client)
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
logger.debug("CallView.updateUIView remote")
|
||||
let camera = view.subviews.first(where: { $0.tag == 0 })!
|
||||
let screen = view.subviews.first(where: { $0.tag == 1 })!
|
||||
let screenVideo = call.peerMediaSources.screenVideo
|
||||
if screenVideo && screen.alpha == 0 {
|
||||
screen.alpha = 1
|
||||
addSubviewAndResize(screen, camera, into: view)
|
||||
} else if !screenVideo && screen.alpha == 1 {
|
||||
screen.alpha = 0
|
||||
addSubviewAndResize(camera, screen, into: view)
|
||||
}
|
||||
(view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
|
||||
(view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
|
||||
|
||||
camera.alpha = call.peerMediaSources.camera ? 1 : 0
|
||||
screen.alpha = call.peerMediaSources.screenVideo ? 1 : 0
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if activeCallViewIsCollapsed != pipShown {
|
||||
enablePip(activeCallViewIsCollapsed)
|
||||
} else if pipShown {
|
||||
context.coordinator.relayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var cameraRenderer: RTCMTLVideoView?
|
||||
var screenRenderer: RTCMTLVideoView?
|
||||
var client: WebRTCClient
|
||||
var pipController: AVPictureInPictureController? = nil
|
||||
var willShowHide: (Bool) -> Void = { _ in }
|
||||
var didShowHide: (Bool) -> Void = { _ in }
|
||||
var relayout: () -> Void = {}
|
||||
|
||||
required init(_ client: WebRTCClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
willShowHide(true)
|
||||
}
|
||||
@@ -180,20 +127,11 @@ struct CallViewRemote: UIViewRepresentable {
|
||||
}
|
||||
|
||||
deinit {
|
||||
// TODO: deinit is not called when changing call type from audio to video and back,
|
||||
// which causes many renderers can be created and added to stream (if enabling/disabling
|
||||
// video while not yet connected in outgoing call)
|
||||
pipController?.stopPictureInPicture()
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
pipController?.contentSource = nil
|
||||
pipController?.delegate = nil
|
||||
pipController = nil
|
||||
if let cameraRenderer {
|
||||
client.removeRemoteCameraRenderer(cameraRenderer)
|
||||
}
|
||||
if let screenRenderer {
|
||||
client.removeRemoteScreenRenderer(screenRenderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,109 +148,51 @@ struct CallViewRemote: UIViewRepresentable {
|
||||
|
||||
struct CallViewLocal: UIViewRepresentable {
|
||||
var client: WebRTCClient
|
||||
var activeCall: Binding<WebRTCClient.Call?>
|
||||
var localRendererAspectRatio: Binding<CGFloat?>
|
||||
@State var pipStateChanged: (Bool) -> Void = {_ in }
|
||||
@Binding var pipShown: Bool
|
||||
|
||||
init(client: WebRTCClient, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
||||
self.client = client
|
||||
self.activeCall = activeCall
|
||||
self.localRendererAspectRatio = localRendererAspectRatio
|
||||
self._pipShown = pipShown
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
let localRenderer = RTCEAGLVideoView(frame: .zero)
|
||||
context.coordinator.renderer = localRenderer
|
||||
client.addLocalRenderer(localRenderer)
|
||||
addSubviewAndResize(localRenderer, nil, into: view)
|
||||
DispatchQueue.main.async {
|
||||
pipStateChanged = { shown in
|
||||
localRenderer.isHidden = shown
|
||||
if let call = activeCall.wrappedValue {
|
||||
let localRenderer = RTCEAGLVideoView(frame: .zero)
|
||||
client.addLocalRenderer(call, localRenderer)
|
||||
client.startCaptureLocalVideo(call)
|
||||
addSubviewAndResize(localRenderer, into: view)
|
||||
DispatchQueue.main.async {
|
||||
pipStateChanged = { shown in
|
||||
localRenderer.isHidden = shown
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(client)
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
logger.debug("CallView.updateUIView local")
|
||||
pipStateChanged(pipShown)
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var renderer: RTCEAGLVideoView?
|
||||
var client: WebRTCClient
|
||||
|
||||
required init(_ client: WebRTCClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let renderer {
|
||||
client.removeLocalRenderer(renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSubviewAndResize(_ fullscreen: UIView, _ end: UIView?, pip: Bool = false, into containerView: UIView) {
|
||||
if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) {
|
||||
// Nothing to do, elements on their places
|
||||
return
|
||||
}
|
||||
containerView.removeConstraints(containerView.constraints)
|
||||
containerView.subviews.forEach { sub in sub.removeFromSuperview()}
|
||||
|
||||
containerView.addSubview(fullscreen)
|
||||
fullscreen.translatesAutoresizingMaskIntoConstraints = false
|
||||
fullscreen.layer.cornerRadius = 0
|
||||
fullscreen.layer.masksToBounds = false
|
||||
|
||||
if let end {
|
||||
containerView.addSubview(end)
|
||||
end.translatesAutoresizingMaskIntoConstraints = false
|
||||
end.layer.cornerRadius = pip ? 8 : 10
|
||||
end.layer.masksToBounds = true
|
||||
}
|
||||
|
||||
let constraintFullscreenV = NSLayoutConstraint.constraints(
|
||||
withVisualFormat: "V:|[fullscreen]|",
|
||||
private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
|
||||
containerView.addSubview(view)
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
|
||||
options: [],
|
||||
metrics: nil,
|
||||
views: ["fullscreen": fullscreen]
|
||||
)
|
||||
let constraintFullscreenH = NSLayoutConstraint.constraints(
|
||||
withVisualFormat: "H:|[fullscreen]|",
|
||||
views: ["view": view]))
|
||||
|
||||
containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
|
||||
options: [],
|
||||
metrics: nil,
|
||||
views: ["fullscreen": fullscreen]
|
||||
)
|
||||
|
||||
containerView.addConstraints(constraintFullscreenV)
|
||||
containerView.addConstraints(constraintFullscreenH)
|
||||
|
||||
if let end {
|
||||
let constraintEndWidth = NSLayoutConstraint(
|
||||
item: end, attribute: .width, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 : 0.3, constant: 0
|
||||
)
|
||||
let constraintEndHeight = NSLayoutConstraint(
|
||||
item: end, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 * 1.33 : 0.3 * 1.33, constant: 0
|
||||
)
|
||||
let constraintEndX = NSLayoutConstraint(
|
||||
item: end, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: pip ? 0.5 : 0.7, constant: pip ? -8 : -17
|
||||
)
|
||||
let constraintEndY = NSLayoutConstraint(
|
||||
item: end, attribute: .bottom, relatedBy: .equal, toItem: containerView, attribute: .bottom, multiplier: 1, constant: pip ? -8 : -92
|
||||
)
|
||||
containerView.addConstraint(constraintEndWidth)
|
||||
containerView.addConstraint(constraintEndHeight)
|
||||
containerView.addConstraint(constraintEndX)
|
||||
containerView.addConstraint(constraintEndY)
|
||||
}
|
||||
views: ["view": view]))
|
||||
containerView.layoutIfNeeded()
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ struct IncomingCallView: View {
|
||||
}
|
||||
HStack {
|
||||
ProfilePreview(profileOf: invitation.contact, color: .white)
|
||||
.padding(.vertical, 6)
|
||||
Spacer()
|
||||
|
||||
callButton("Reject", "phone.down.fill", .red) {
|
||||
|
||||
@@ -18,49 +18,49 @@ class Call: ObservableObject, Equatable {
|
||||
|
||||
var direction: CallDirection
|
||||
var contact: Contact
|
||||
var callUUID: String?
|
||||
var initialCallType: CallMediaType
|
||||
@Published var localMediaSources: CallMediaSources
|
||||
var callkitUUID: UUID?
|
||||
var localMedia: CallMediaType
|
||||
@Published var callState: CallState
|
||||
@Published var localCapabilities: CallCapabilities?
|
||||
@Published var peerMediaSources: CallMediaSources = CallMediaSources()
|
||||
@Published var peerMedia: CallMediaType?
|
||||
@Published var sharedKey: String?
|
||||
@Published var audioEnabled = true
|
||||
@Published var speakerEnabled = false
|
||||
@Published var videoEnabled: Bool
|
||||
@Published var connectionInfo: ConnectionInfo?
|
||||
@Published var connectedAt: Date? = nil
|
||||
|
||||
init(
|
||||
direction: CallDirection,
|
||||
contact: Contact,
|
||||
callUUID: String?,
|
||||
callkitUUID: UUID?,
|
||||
callState: CallState,
|
||||
initialCallType: CallMediaType,
|
||||
localMedia: CallMediaType,
|
||||
sharedKey: String? = nil
|
||||
) {
|
||||
self.direction = direction
|
||||
self.contact = contact
|
||||
self.callUUID = callUUID
|
||||
self.callkitUUID = callkitUUID
|
||||
self.callState = callState
|
||||
self.initialCallType = initialCallType
|
||||
self.localMedia = localMedia
|
||||
self.sharedKey = sharedKey
|
||||
self.localMediaSources = CallMediaSources(
|
||||
mic: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized,
|
||||
camera: initialCallType == .video && AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
|
||||
self.videoEnabled = localMedia == .video
|
||||
}
|
||||
|
||||
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
|
||||
private var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
|
||||
var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
|
||||
var encryptionStatus: LocalizedStringKey {
|
||||
get {
|
||||
switch callState {
|
||||
case .waitCapabilities: return ""
|
||||
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
|
||||
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
|
||||
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
|
||||
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
|
||||
}
|
||||
}
|
||||
}
|
||||
var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } }
|
||||
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
||||
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
|
||||
}
|
||||
|
||||
enum CallDirection {
|
||||
@@ -105,28 +105,18 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
|
||||
var command: WCallCommand?
|
||||
}
|
||||
|
||||
struct CallMediaSources: Equatable, Codable {
|
||||
var mic: Bool = false
|
||||
var camera: Bool = false
|
||||
var screenAudio: Bool = false
|
||||
var screenVideo: Bool = false
|
||||
|
||||
var hasVideo: Bool { get { camera || screenVideo } }
|
||||
}
|
||||
|
||||
enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
case capabilities(media: CallMediaType)
|
||||
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||
case answer(answer: String, iceCandidates: String)
|
||||
case ice(iceCandidates: String)
|
||||
case media(source: CallMediaSource, enable: Bool)
|
||||
case media(media: CallMediaType, enable: Bool)
|
||||
case end
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case media
|
||||
case source
|
||||
case aesKey
|
||||
case offer
|
||||
case answer
|
||||
@@ -177,9 +167,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
case let .ice(iceCandidates):
|
||||
try container.encode("ice", forKey: .type)
|
||||
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||
case let .media(source, enable):
|
||||
case let .media(media, enable):
|
||||
try container.encode("media", forKey: .type)
|
||||
try container.encode(source, forKey: .media)
|
||||
try container.encode(media, forKey: .media)
|
||||
try container.encode(enable, forKey: .enable)
|
||||
case .end:
|
||||
try container.encode("end", forKey: .type)
|
||||
@@ -215,9 +205,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
||||
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||
self = .ice(iceCandidates: iceCandidates)
|
||||
case "media":
|
||||
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
|
||||
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
||||
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
|
||||
self = .media(source: source, enable: enable)
|
||||
self = .media(media: media, enable: enable)
|
||||
case "end":
|
||||
self = .end
|
||||
default:
|
||||
@@ -234,7 +224,6 @@ enum WCallResponse: Equatable, Decodable {
|
||||
case ice(iceCandidates: String)
|
||||
case connection(state: ConnectionState)
|
||||
case connected(connectionInfo: ConnectionInfo)
|
||||
case peerMedia(source: CallMediaSource, enabled: Bool)
|
||||
case ended
|
||||
case ok
|
||||
case error(message: String)
|
||||
@@ -249,8 +238,6 @@ enum WCallResponse: Equatable, Decodable {
|
||||
case state
|
||||
case connectionInfo
|
||||
case message
|
||||
case source
|
||||
case enabled
|
||||
}
|
||||
|
||||
var respType: String {
|
||||
@@ -262,7 +249,6 @@ enum WCallResponse: Equatable, Decodable {
|
||||
case .ice: return "ice"
|
||||
case .connection: return "connection"
|
||||
case .connected: return "connected"
|
||||
case .peerMedia: return "peerMedia"
|
||||
case .ended: return "ended"
|
||||
case .ok: return "ok"
|
||||
case .error: return "error"
|
||||
@@ -297,10 +283,6 @@ enum WCallResponse: Equatable, Decodable {
|
||||
case "connected":
|
||||
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
|
||||
self = .connected(connectionInfo: connectionInfo)
|
||||
case "peerMedia":
|
||||
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
|
||||
let enabled = try container.decode(Bool.self, forKey: CodingKeys.enabled)
|
||||
self = .peerMedia(source: source, enabled: enabled)
|
||||
case "ended":
|
||||
self = .ended
|
||||
case "ok":
|
||||
@@ -342,10 +324,6 @@ extension WCallResponse: Encodable {
|
||||
case let .connected(connectionInfo):
|
||||
try container.encode("connected", forKey: .type)
|
||||
try container.encode(connectionInfo, forKey: .connectionInfo)
|
||||
case let .peerMedia(source, enabled):
|
||||
try container.encode("peerMedia", forKey: .type)
|
||||
try container.encode(source, forKey: .source)
|
||||
try container.encode(enabled, forKey: .enabled)
|
||||
case .ended:
|
||||
try container.encode("ended", forKey: .type)
|
||||
case .ok:
|
||||
@@ -398,7 +376,7 @@ actor WebRTCCommandProcessor {
|
||||
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
|
||||
switch c {
|
||||
case .capabilities, .start, .offer, .end: true
|
||||
default: client.activeCall != nil
|
||||
default: client.activeCall.wrappedValue != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,24 +23,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
struct Call {
|
||||
var connection: RTCPeerConnection
|
||||
var iceCandidates: IceCandidates
|
||||
var localMedia: CallMediaType
|
||||
var localCamera: RTCVideoCapturer?
|
||||
var localAudioTrack: RTCAudioTrack?
|
||||
var localVideoTrack: RTCVideoTrack?
|
||||
var remoteAudioTrack: RTCAudioTrack?
|
||||
var remoteVideoTrack: RTCVideoTrack?
|
||||
var remoteScreenAudioTrack: RTCAudioTrack?
|
||||
var remoteScreenVideoTrack: RTCVideoTrack?
|
||||
var device: AVCaptureDevice.Position
|
||||
var localVideoSource: RTCVideoSource?
|
||||
var localStream: RTCVideoTrack?
|
||||
var remoteStream: RTCVideoTrack?
|
||||
var device: AVCaptureDevice.Position = .front
|
||||
var aesKey: String?
|
||||
var frameEncryptor: RTCFrameEncryptor?
|
||||
var frameDecryptor: RTCFrameDecryptor?
|
||||
var peerHasOldVersion: Bool
|
||||
}
|
||||
|
||||
struct NotConnectedCall {
|
||||
var audioTrack: RTCAudioTrack?
|
||||
var localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)?
|
||||
var device: AVCaptureDevice.Position = .front
|
||||
}
|
||||
|
||||
actor IceCandidates {
|
||||
@@ -60,20 +51,17 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
private let rtcAudioSession = RTCAudioSession.sharedInstance()
|
||||
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio")
|
||||
private var sendCallResponse: (WVAPIMessage) async -> Void
|
||||
var activeCall: Call?
|
||||
var notConnectedCall: NotConnectedCall?
|
||||
var activeCall: Binding<Call?>
|
||||
private var localRendererAspectRatio: Binding<CGFloat?>
|
||||
|
||||
var cameraRenderers: [RTCVideoRenderer] = []
|
||||
var screenRenderers: [RTCVideoRenderer] = []
|
||||
|
||||
@available(*, unavailable)
|
||||
override init() {
|
||||
fatalError("Unimplemented")
|
||||
}
|
||||
|
||||
required init(_ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) {
|
||||
required init(_ activeCall: Binding<Call?>, _ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) {
|
||||
self.sendCallResponse = sendCallResponse
|
||||
self.activeCall = activeCall
|
||||
self.localRendererAspectRatio = localRendererAspectRatio
|
||||
rtcAudioSession.useManualAudio = CallController.useCallKit()
|
||||
rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
|
||||
@@ -90,45 +78,39 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
||||
connection.delegate = self
|
||||
let device = notConnectedCall?.device ?? .front
|
||||
createAudioSender(connection)
|
||||
var localStream: RTCVideoTrack? = nil
|
||||
var remoteStream: RTCVideoTrack? = nil
|
||||
var localCamera: RTCVideoCapturer? = nil
|
||||
var localAudioTrack: RTCAudioTrack? = nil
|
||||
var localVideoTrack: RTCVideoTrack? = nil
|
||||
if let localCameraAndTrack = notConnectedCall?.localCameraAndTrack {
|
||||
(localCamera, localVideoTrack) = localCameraAndTrack
|
||||
} else if notConnectedCall == nil && mediaType == .video {
|
||||
(localCamera, localVideoTrack) = createVideoTrackAndStartCapture(device)
|
||||
var localVideoSource: RTCVideoSource? = nil
|
||||
if mediaType == .video {
|
||||
(localStream, remoteStream, localCamera, localVideoSource) = createVideoSender(connection)
|
||||
}
|
||||
if let audioTrack = notConnectedCall?.audioTrack {
|
||||
localAudioTrack = audioTrack
|
||||
} else if notConnectedCall == nil {
|
||||
localAudioTrack = createAudioTrack()
|
||||
}
|
||||
notConnectedCall?.localCameraAndTrack = nil
|
||||
notConnectedCall?.audioTrack = nil
|
||||
|
||||
var frameEncryptor: RTCFrameEncryptor? = nil
|
||||
var frameDecryptor: RTCFrameDecryptor? = nil
|
||||
if aesKey != nil {
|
||||
let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes))
|
||||
encryptor.delegate = self
|
||||
frameEncryptor = encryptor
|
||||
connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
|
||||
|
||||
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
|
||||
decryptor.delegate = self
|
||||
frameDecryptor = decryptor
|
||||
// Has no video receiver in outgoing call if applied here, see [peerConnection(_ connection: RTCPeerConnection, didChange newState]
|
||||
// connection.receivers.forEach { $0.setRtcFrameDecryptor(decryptor) }
|
||||
}
|
||||
return Call(
|
||||
connection: connection,
|
||||
iceCandidates: IceCandidates(),
|
||||
localMedia: mediaType,
|
||||
localCamera: localCamera,
|
||||
localAudioTrack: localAudioTrack,
|
||||
localVideoTrack: localVideoTrack,
|
||||
device: device,
|
||||
localVideoSource: localVideoSource,
|
||||
localStream: localStream,
|
||||
remoteStream: remoteStream,
|
||||
aesKey: aesKey,
|
||||
frameEncryptor: frameEncryptor,
|
||||
frameDecryptor: frameDecryptor,
|
||||
peerHasOldVersion: false
|
||||
frameDecryptor: frameDecryptor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,24 +151,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
|
||||
func sendCallCommand(command: WCallCommand) async {
|
||||
var resp: WCallResponse? = nil
|
||||
let pc = activeCall?.connection
|
||||
let pc = activeCall.wrappedValue?.connection
|
||||
switch command {
|
||||
case let .capabilities(media): // outgoing
|
||||
let localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)? = media == .video
|
||||
? createVideoTrackAndStartCapture(.front)
|
||||
: nil
|
||||
notConnectedCall = NotConnectedCall(audioTrack: createAudioTrack(), localCameraAndTrack: localCameraAndTrack, device: .front)
|
||||
case .capabilities:
|
||||
resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption))
|
||||
case let .start(media: media, aesKey, iceServers, relay): // incoming
|
||||
case let .start(media: media, aesKey, iceServers, relay):
|
||||
logger.debug("starting incoming call - create webrtc session")
|
||||
if activeCall != nil { endCall() }
|
||||
if activeCall.wrappedValue != nil { endCall() }
|
||||
let encryption = WebRTCClient.enableEncryption
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
|
||||
activeCall = call
|
||||
setupLocalTracks(true, call)
|
||||
activeCall.wrappedValue = call
|
||||
let (offer, error) = await call.connection.offer()
|
||||
if let offer = offer {
|
||||
setupEncryptionForLocalTracks(call)
|
||||
resp = .offer(
|
||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
|
||||
@@ -196,24 +172,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
} else {
|
||||
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
}
|
||||
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): // outgoing
|
||||
if activeCall != nil {
|
||||
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
|
||||
if activeCall.wrappedValue != nil {
|
||||
resp = .error(message: "accept: call already started")
|
||||
} else if !WebRTCClient.enableEncryption && aesKey != nil {
|
||||
resp = .error(message: "accept: encryption is not supported")
|
||||
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
|
||||
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||
activeCall = call
|
||||
activeCall.wrappedValue = call
|
||||
let pc = call.connection
|
||||
if let type = offer.type, let sdp = offer.sdp {
|
||||
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
||||
setupLocalTracks(false, call)
|
||||
setupEncryptionForLocalTracks(call)
|
||||
pc.transceivers.forEach { transceiver in
|
||||
transceiver.setDirection(.sendRecv, error: nil)
|
||||
}
|
||||
await adaptToOldVersion(pc.transceivers.count <= 2)
|
||||
let (answer, error) = await pc.answer()
|
||||
if let answer = answer {
|
||||
self.addIceCandidates(pc, remoteIceCandidates)
|
||||
@@ -230,7 +200,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .answer(answer, iceCandidates): // incoming
|
||||
case let .answer(answer, iceCandidates):
|
||||
if pc == nil {
|
||||
resp = .error(message: "answer: call not started")
|
||||
} else if pc?.localDescription == nil {
|
||||
@@ -242,9 +212,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
let type = answer.type, let sdp = answer.sdp,
|
||||
let pc = pc {
|
||||
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
||||
var currentDirection: RTCRtpTransceiverDirection = .sendOnly
|
||||
pc.transceivers[2].currentDirection(¤tDirection)
|
||||
await adaptToOldVersion(currentDirection == .sendOnly)
|
||||
addIceCandidates(pc, remoteIceCandidates)
|
||||
resp = .ok
|
||||
} else {
|
||||
@@ -259,11 +226,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
} else {
|
||||
resp = .error(message: "ice: call not started")
|
||||
}
|
||||
case let .media(source, enable):
|
||||
if activeCall == nil {
|
||||
case let .media(media, enable):
|
||||
if activeCall.wrappedValue == nil {
|
||||
resp = .error(message: "media: call not started")
|
||||
} else if activeCall.wrappedValue?.localMedia == .audio && media == .video {
|
||||
resp = .error(message: "media: no video")
|
||||
} else {
|
||||
await enableMedia(source, enable)
|
||||
enableMedia(media, enable)
|
||||
resp = .ok
|
||||
}
|
||||
case .end:
|
||||
@@ -278,7 +247,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
|
||||
func getInitialIceCandidates() async -> [RTCIceCandidate] {
|
||||
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
|
||||
let candidates = await activeCall?.iceCandidates.getAndClear() ?? []
|
||||
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
|
||||
return candidates
|
||||
}
|
||||
@@ -286,7 +255,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
func waitForMoreIceCandidates() {
|
||||
Task {
|
||||
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
|
||||
let candidates = await self.activeCall?.iceCandidates.getAndClear() ?? []
|
||||
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||
if candidates.count > 0 {
|
||||
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
|
||||
await self.sendIceCandidates(candidates)
|
||||
@@ -303,202 +272,25 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
)
|
||||
}
|
||||
|
||||
func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) {
|
||||
// logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)")
|
||||
Task {
|
||||
var lastBytesReceived: Int64 = 0
|
||||
// muted initially
|
||||
var mutedSeconds = 4
|
||||
while let call = self.activeCall, transceiver.receiver.track?.readyState == .live {
|
||||
let stats: RTCStatisticsReport = await call.connection.statistics(for: transceiver.receiver)
|
||||
let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"})
|
||||
if let stat {
|
||||
//logger.debug("Stat \(stat.debugDescription)")
|
||||
let bytes = stat.values["bytesReceived"] as! Int64
|
||||
if bytes <= lastBytesReceived {
|
||||
mutedSeconds += 1
|
||||
if mutedSeconds == 3 {
|
||||
await MainActor.run {
|
||||
self.onMediaMuteUnmute(transceiver.mid, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mutedSeconds >= 3 {
|
||||
await MainActor.run {
|
||||
self.onMediaMuteUnmute(transceiver.mid, false)
|
||||
}
|
||||
}
|
||||
lastBytesReceived = bytes
|
||||
mutedSeconds = 0
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1000_000000)
|
||||
}
|
||||
}
|
||||
func enableMedia(_ media: CallMediaType, _ enable: Bool) {
|
||||
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
|
||||
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func onMediaMuteUnmute(_ transceiverMid: String?, _ mute: Bool) {
|
||||
guard let activeCall = ChatModel.shared.activeCall else { return }
|
||||
let source = mediaSourceFromTransceiverMid(transceiverMid)
|
||||
logger.log("Mute/unmute \(source.rawValue) track = \(mute) with mid = \(transceiverMid ?? "nil")")
|
||||
if source == .mic && activeCall.peerMediaSources.mic == mute {
|
||||
activeCall.peerMediaSources.mic = !mute
|
||||
} else if (source == .camera && activeCall.peerMediaSources.camera == mute) {
|
||||
activeCall.peerMediaSources.camera = !mute
|
||||
} else if (source == .screenAudio && activeCall.peerMediaSources.screenAudio == mute) {
|
||||
activeCall.peerMediaSources.screenAudio = !mute
|
||||
} else if (source == .screenVideo && activeCall.peerMediaSources.screenVideo == mute) {
|
||||
activeCall.peerMediaSources.screenVideo = !mute
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
|
||||
logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
|
||||
source == .camera ? setCameraEnabled(enable) : setAudioEnabled(enable)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func adaptToOldVersion(_ peerHasOldVersion: Bool) {
|
||||
activeCall?.peerHasOldVersion = peerHasOldVersion
|
||||
if peerHasOldVersion {
|
||||
logger.debug("The peer has an old version. Remote audio track is nil = \(self.activeCall?.remoteAudioTrack == nil), video = \(self.activeCall?.remoteVideoTrack == nil)")
|
||||
onMediaMuteUnmute("0", false)
|
||||
if activeCall?.remoteVideoTrack != nil {
|
||||
onMediaMuteUnmute("1", false)
|
||||
}
|
||||
if ChatModel.shared.activeCall?.localMediaSources.camera == true && ChatModel.shared.activeCall?.peerMediaSources.camera == false {
|
||||
logger.debug("Stopping video track for the old version")
|
||||
activeCall?.connection.senders[1].track = nil
|
||||
ChatModel.shared.activeCall?.localMediaSources.camera = false
|
||||
(activeCall?.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
activeCall?.localCamera = nil
|
||||
activeCall?.localVideoTrack = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addLocalRenderer(_ renderer: RTCEAGLVideoView) {
|
||||
if let activeCall {
|
||||
if let track = activeCall.localVideoTrack {
|
||||
track.add(renderer)
|
||||
}
|
||||
} else if let notConnectedCall {
|
||||
if let track = notConnectedCall.localCameraAndTrack?.1 {
|
||||
track.add(renderer)
|
||||
}
|
||||
}
|
||||
func addLocalRenderer(_ activeCall: Call, _ renderer: RTCEAGLVideoView) {
|
||||
activeCall.localStream?.add(renderer)
|
||||
// To get width and height of a frame, see videoView(videoView:, didChangeVideoSize)
|
||||
renderer.delegate = self
|
||||
}
|
||||
|
||||
func removeLocalRenderer(_ renderer: RTCEAGLVideoView) {
|
||||
if let activeCall {
|
||||
if let track = activeCall.localVideoTrack {
|
||||
track.remove(renderer)
|
||||
}
|
||||
} else if let notConnectedCall {
|
||||
if let track = notConnectedCall.localCameraAndTrack?.1 {
|
||||
track.remove(renderer)
|
||||
}
|
||||
}
|
||||
renderer.delegate = nil
|
||||
}
|
||||
|
||||
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
|
||||
guard size.height > 0 else { return }
|
||||
localRendererAspectRatio.wrappedValue = size.width / size.height
|
||||
}
|
||||
|
||||
func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
|
||||
let pc = call.connection
|
||||
let transceivers = call.connection.transceivers
|
||||
let audioTrack = call.localAudioTrack
|
||||
let videoTrack = call.localVideoTrack
|
||||
|
||||
if incomingCall {
|
||||
let micCameraInit = RTCRtpTransceiverInit()
|
||||
// streamIds required for old versions which adds tracks from stream, not from track property
|
||||
micCameraInit.streamIds = ["micCamera"]
|
||||
|
||||
let screenAudioVideoInit = RTCRtpTransceiverInit()
|
||||
screenAudioVideoInit.streamIds = ["screenAudioVideo"]
|
||||
|
||||
// incoming call, no transceivers yet. But they should be added in order: mic, camera, screen audio, screen video
|
||||
// mid = 0, mic
|
||||
if let audioTrack {
|
||||
pc.addTransceiver(with: audioTrack, init: micCameraInit)
|
||||
} else {
|
||||
pc.addTransceiver(of: .audio, init: micCameraInit)
|
||||
}
|
||||
// mid = 1, camera
|
||||
if let videoTrack {
|
||||
pc.addTransceiver(with: videoTrack, init: micCameraInit)
|
||||
} else {
|
||||
pc.addTransceiver(of: .video, init: micCameraInit)
|
||||
}
|
||||
// mid = 2, screenAudio
|
||||
pc.addTransceiver(of: .audio, init: screenAudioVideoInit)
|
||||
// mid = 3, screenVideo
|
||||
pc.addTransceiver(of: .video, init: screenAudioVideoInit)
|
||||
} else {
|
||||
// new version
|
||||
if transceivers.count > 2 {
|
||||
// Outgoing call. All transceivers are ready. Don't addTrack() because it will create new transceivers, replace existing (nil) tracks
|
||||
transceivers
|
||||
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .mic })?
|
||||
.sender.track = audioTrack
|
||||
transceivers
|
||||
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .camera })?
|
||||
.sender.track = videoTrack
|
||||
} else {
|
||||
// old version, only two transceivers
|
||||
if let audioTrack {
|
||||
pc.add(audioTrack, streamIds: ["micCamera"])
|
||||
} else {
|
||||
// it's important to have any track in order to be able to turn it on again (currently it's off)
|
||||
let sender = pc.add(createAudioTrack(), streamIds: ["micCamera"])
|
||||
sender?.track = nil
|
||||
}
|
||||
if let videoTrack {
|
||||
pc.add(videoTrack, streamIds: ["micCamera"])
|
||||
} else {
|
||||
// it's important to have any track in order to be able to turn it on again (currently it's off)
|
||||
let localVideoSource = WebRTCClient.factory.videoSource()
|
||||
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
|
||||
let sender = pc.add(localVideoTrack, streamIds: ["micCamera"])
|
||||
sender?.track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaSourceFromTransceiverMid(_ mid: String?) -> CallMediaSource {
|
||||
switch mid {
|
||||
case "0":
|
||||
return .mic
|
||||
case "1":
|
||||
return .camera
|
||||
case "2":
|
||||
return .screenAudio
|
||||
case "3":
|
||||
return .screenVideo
|
||||
default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Should be called after local description set
|
||||
func setupEncryptionForLocalTracks(_ call: Call) {
|
||||
if let encryptor = call.frameEncryptor {
|
||||
call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
|
||||
}
|
||||
}
|
||||
|
||||
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
|
||||
guard encrypted.count > 0 else { return nil }
|
||||
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
|
||||
if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
|
||||
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
|
||||
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
|
||||
let isKeyFrame = encrypted[0] & 1 == 0
|
||||
@@ -512,7 +304,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
|
||||
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
|
||||
guard unencrypted.count > 0 else { return nil }
|
||||
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
|
||||
if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
|
||||
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
|
||||
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
|
||||
let isKeyFrame = unencrypted[0] & 1 == 0
|
||||
@@ -535,42 +327,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
func addRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
|
||||
if activeCall?.remoteVideoTrack != nil {
|
||||
activeCall?.remoteVideoTrack?.add(renderer)
|
||||
} else {
|
||||
cameraRenderers.append(renderer)
|
||||
}
|
||||
func addRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
||||
activeCall.remoteStream?.add(renderer)
|
||||
}
|
||||
|
||||
func removeRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
|
||||
if activeCall?.remoteVideoTrack != nil {
|
||||
activeCall?.remoteVideoTrack?.remove(renderer)
|
||||
} else {
|
||||
cameraRenderers.removeAll(where: { $0.isEqual(renderer) })
|
||||
}
|
||||
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
||||
activeCall.remoteStream?.remove(renderer)
|
||||
}
|
||||
|
||||
func addRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
|
||||
if activeCall?.remoteScreenVideoTrack != nil {
|
||||
activeCall?.remoteScreenVideoTrack?.add(renderer)
|
||||
} else {
|
||||
screenRenderers.append(renderer)
|
||||
}
|
||||
}
|
||||
|
||||
func removeRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
|
||||
if activeCall?.remoteScreenVideoTrack != nil {
|
||||
activeCall?.remoteScreenVideoTrack?.remove(renderer)
|
||||
} else {
|
||||
screenRenderers.removeAll(where: { $0.isEqual(renderer) })
|
||||
}
|
||||
}
|
||||
|
||||
func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
|
||||
func startCaptureLocalVideo(_ activeCall: Call) {
|
||||
#if targetEnvironment(simulator)
|
||||
guard
|
||||
let capturer = (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCFileVideoCapturer
|
||||
let capturer = activeCall.localCamera as? RTCFileVideoCapturer
|
||||
else {
|
||||
logger.error("Unable to work with a file capturer")
|
||||
return
|
||||
@@ -580,10 +348,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
|
||||
#else
|
||||
guard
|
||||
let capturer = capturer as? RTCCameraVideoCapturer,
|
||||
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == device })
|
||||
let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
|
||||
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
|
||||
else {
|
||||
logger.error("Unable to find a camera or local track")
|
||||
logger.error("Unable to find a camera")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -609,6 +377,19 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
#endif
|
||||
}
|
||||
|
||||
private func createAudioSender(_ connection: RTCPeerConnection) {
|
||||
let streamId = "stream"
|
||||
let audioTrack = createAudioTrack()
|
||||
connection.add(audioTrack, streamIds: [streamId])
|
||||
}
|
||||
|
||||
private func createVideoSender(_ connection: RTCPeerConnection) -> (RTCVideoTrack?, RTCVideoTrack?, RTCVideoCapturer?, RTCVideoSource?) {
|
||||
let streamId = "stream"
|
||||
let (localVideoTrack, localCamera, localVideoSource) = createVideoTrack()
|
||||
connection.add(localVideoTrack, streamIds: [streamId])
|
||||
return (localVideoTrack, connection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack, localCamera, localVideoSource)
|
||||
}
|
||||
|
||||
private func createAudioTrack() -> RTCAudioTrack {
|
||||
let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
||||
let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains)
|
||||
@@ -616,7 +397,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
return audioTrack
|
||||
}
|
||||
|
||||
private func createVideoTrackAndStartCapture(_ device: AVCaptureDevice.Position) -> (RTCVideoCapturer, RTCVideoTrack) {
|
||||
private func createVideoTrack() -> (RTCVideoTrack, RTCVideoCapturer, RTCVideoSource) {
|
||||
let localVideoSource = WebRTCClient.factory.videoSource()
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
@@ -626,30 +407,19 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
#endif
|
||||
|
||||
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
|
||||
startCaptureLocalVideo(device, localCamera)
|
||||
return (localCamera, localVideoTrack)
|
||||
return (localVideoTrack, localCamera, localVideoSource)
|
||||
}
|
||||
|
||||
func endCall() {
|
||||
if #available(iOS 16.0, *) {
|
||||
_endCall()
|
||||
} else {
|
||||
// Fixes `connection.close()` getting locked up in iOS15
|
||||
DispatchQueue.global(qos: .utility).async { self._endCall() }
|
||||
}
|
||||
}
|
||||
|
||||
private func _endCall() {
|
||||
(notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
guard let call = activeCall else { return }
|
||||
guard let call = activeCall.wrappedValue else { return }
|
||||
logger.debug("WebRTCClient: ending the call")
|
||||
activeCall.wrappedValue = nil
|
||||
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
call.connection.close()
|
||||
call.connection.delegate = nil
|
||||
call.frameEncryptor?.delegate = nil
|
||||
call.frameDecryptor?.delegate = nil
|
||||
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
audioSessionToDefaults()
|
||||
activeCall = nil
|
||||
}
|
||||
|
||||
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
|
||||
@@ -658,7 +428,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||
t += stepMs
|
||||
await action()
|
||||
} while t < timeoutMs && activeCall?.connection.iceGatheringState != .complete
|
||||
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,40 +489,11 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
logger.debug("Connection should negotiate")
|
||||
}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
|
||||
if let track = transceiver.receiver.track {
|
||||
DispatchQueue.main.async {
|
||||
// Doesn't work for outgoing video call (audio in video call works ok still, same as incoming call)
|
||||
// if let decryptor = self.activeCall?.frameDecryptor {
|
||||
// transceiver.receiver.setRtcFrameDecryptor(decryptor)
|
||||
// }
|
||||
let source = self.mediaSourceFromTransceiverMid(transceiver.mid)
|
||||
switch source {
|
||||
case .mic: self.activeCall?.remoteAudioTrack = track as? RTCAudioTrack
|
||||
case .camera:
|
||||
self.activeCall?.remoteVideoTrack = track as? RTCVideoTrack
|
||||
self.cameraRenderers.forEach({ renderer in
|
||||
self.activeCall?.remoteVideoTrack?.add(renderer)
|
||||
})
|
||||
self.cameraRenderers.removeAll()
|
||||
case .screenAudio: self.activeCall?.remoteScreenAudioTrack = track as? RTCAudioTrack
|
||||
case .screenVideo:
|
||||
self.activeCall?.remoteScreenVideoTrack = track as? RTCVideoTrack
|
||||
self.screenRenderers.forEach({ renderer in
|
||||
self.activeCall?.remoteScreenVideoTrack?.add(renderer)
|
||||
})
|
||||
self.screenRenderers.removeAll()
|
||||
case .unknown: ()
|
||||
}
|
||||
}
|
||||
self.setupMuteUnmuteListener(transceiver, track)
|
||||
}
|
||||
}
|
||||
|
||||
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
||||
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
|
||||
|
||||
guard let connectionStateString = newState.toString(),
|
||||
guard let call = activeCall.wrappedValue,
|
||||
let connectionStateString = newState.toString(),
|
||||
let iceConnectionStateString = connection.iceConnectionState.toString(),
|
||||
let iceGatheringStateString = connection.iceGatheringState.toString(),
|
||||
let signalingStateString = connection.signalingState.toString()
|
||||
@@ -773,14 +514,18 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
|
||||
switch newState {
|
||||
case .checking:
|
||||
if let frameDecryptor = activeCall?.frameDecryptor {
|
||||
if let frameDecryptor = activeCall.wrappedValue?.frameDecryptor {
|
||||
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
|
||||
}
|
||||
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true
|
||||
let enableSpeaker: Bool
|
||||
switch call.localMedia {
|
||||
case .video: enableSpeaker = true
|
||||
default: enableSpeaker = false
|
||||
}
|
||||
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
||||
case .connected: sendConnectedEvent(connection)
|
||||
case .disconnected, .failed: endCall()
|
||||
default: ()
|
||||
default: do {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -792,7 +537,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
|
||||
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
|
||||
Task {
|
||||
await self.activeCall?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
||||
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,42 +592,11 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
}
|
||||
|
||||
extension WebRTCClient {
|
||||
static func isAuthorized(for type: AVMediaType) async -> Bool {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: type)
|
||||
var isAuthorized = status == .authorized
|
||||
if status == .notDetermined {
|
||||
isAuthorized = await AVCaptureDevice.requestAccess(for: type)
|
||||
}
|
||||
return isAuthorized
|
||||
func setAudioEnabled(_ enabled: Bool) {
|
||||
setTrackEnabled(RTCAudioTrack.self, enabled)
|
||||
}
|
||||
|
||||
static func showUnauthorizedAlert(for type: AVMediaType) {
|
||||
if type == .audio {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("No permission to record speech"),
|
||||
message: Text("To record speech please grant permission to use Microphone."),
|
||||
primaryButton: .default(Text("Open Settings")) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
} else if type == .video {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("No permission to record video"),
|
||||
message: Text("To record video please grant permission to use Camera."),
|
||||
primaryButton: .default(Text("Open Settings")) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func setSpeakerEnabledAndConfigureSession( _ enabled: Bool, skipExternalDevice: Bool = false) {
|
||||
func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) {
|
||||
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)")
|
||||
audioQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -895,7 +609,7 @@ extension WebRTCClient {
|
||||
if enabled {
|
||||
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
|
||||
if hasExternalAudioDevice && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
|
||||
if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
|
||||
try self.rtcAudioSession.setPreferredInput(preferred)
|
||||
} else {
|
||||
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
|
||||
@@ -905,7 +619,7 @@ extension WebRTCClient {
|
||||
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
|
||||
try self.rtcAudioSession.overrideOutputAudioPort(.none)
|
||||
}
|
||||
if hasExternalAudioDevice && !skipExternalDevice {
|
||||
if hasExternalAudioDevice {
|
||||
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
|
||||
}
|
||||
try self.rtcAudioSession.setActive(true)
|
||||
@@ -936,59 +650,25 @@ extension WebRTCClient {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setAudioEnabled(_ enabled: Bool) {
|
||||
if activeCall != nil {
|
||||
activeCall?.localAudioTrack = enabled ? createAudioTrack() : nil
|
||||
activeCall?.connection.transceivers.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .mic })?.sender.track = activeCall?.localAudioTrack
|
||||
} else if notConnectedCall != nil {
|
||||
notConnectedCall?.audioTrack = enabled ? createAudioTrack() : nil
|
||||
}
|
||||
ChatModel.shared.activeCall?.localMediaSources.mic = enabled
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setCameraEnabled(_ enabled: Bool) {
|
||||
if let call = activeCall {
|
||||
if enabled {
|
||||
if call.localVideoTrack == nil {
|
||||
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
|
||||
let (camera, track) = createVideoTrackAndStartCapture(device)
|
||||
activeCall?.localCamera = camera
|
||||
activeCall?.localVideoTrack = track
|
||||
}
|
||||
} else {
|
||||
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
activeCall?.localCamera = nil
|
||||
activeCall?.localVideoTrack = nil
|
||||
}
|
||||
call.connection.transceivers
|
||||
.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .camera })?
|
||||
.sender.track = activeCall?.localVideoTrack
|
||||
ChatModel.shared.activeCall?.localMediaSources.camera = activeCall?.localVideoTrack != nil
|
||||
} else if let call = notConnectedCall {
|
||||
if enabled {
|
||||
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
|
||||
notConnectedCall?.localCameraAndTrack = createVideoTrackAndStartCapture(device)
|
||||
} else {
|
||||
(call.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
|
||||
notConnectedCall?.localCameraAndTrack = nil
|
||||
}
|
||||
ChatModel.shared.activeCall?.localMediaSources.camera = notConnectedCall?.localCameraAndTrack != nil
|
||||
}
|
||||
func setVideoEnabled(_ enabled: Bool) {
|
||||
setTrackEnabled(RTCVideoTrack.self, enabled)
|
||||
}
|
||||
|
||||
func flipCamera() {
|
||||
let device = activeCall?.device ?? notConnectedCall?.device
|
||||
if activeCall != nil {
|
||||
activeCall?.device = device == .front ? .back : .front
|
||||
} else {
|
||||
notConnectedCall?.device = device == .front ? .back : .front
|
||||
switch activeCall.wrappedValue?.device {
|
||||
case .front: activeCall.wrappedValue?.device = .back
|
||||
case .back: activeCall.wrappedValue?.device = .front
|
||||
default: ()
|
||||
}
|
||||
startCaptureLocalVideo(
|
||||
activeCall?.device ?? notConnectedCall?.device,
|
||||
(activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCCameraVideoCapturer
|
||||
)
|
||||
if let call = activeCall.wrappedValue {
|
||||
startCaptureLocalVideo(call)
|
||||
}
|
||||
}
|
||||
|
||||
private func setTrackEnabled<T: RTCMediaStreamTrack>(_ type: T.Type, _ enabled: Bool) {
|
||||
activeCall.wrappedValue?.connection.transceivers
|
||||
.compactMap { $0.sender.track as? T }
|
||||
.forEach { $0.isEnabled = enabled }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -671,14 +671,7 @@ private struct CallButton: View {
|
||||
|
||||
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
|
||||
if canCall {
|
||||
if CallController.useCallKit() {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
} else {
|
||||
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
|
||||
dismissAllSheets(animated: true) {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
}
|
||||
}
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
} else if contact.nextSendGrpInv {
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(
|
||||
@@ -942,7 +935,7 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
|
||||
)
|
||||
}
|
||||
|
||||
func queueInfoText(_ info: (RcvMsgInfo?, ServerQueueInfo)) -> String {
|
||||
func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
|
||||
let (rcvMsgInfo, qInfo) = info
|
||||
var msgInfo: String
|
||||
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
|
||||
|
||||
@@ -11,11 +11,11 @@ import SimpleXChat
|
||||
|
||||
struct CIChatFeatureView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.revealed) var revealed: Bool
|
||||
@ObservedObject var im = ItemsModel.shared
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var feature: Feature
|
||||
var icon: String? = nil
|
||||
var iconColor: Color
|
||||
@@ -106,9 +106,6 @@ struct CIChatFeatureView: View {
|
||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||
CIChatFeatureView(
|
||||
chat: Chat.sampleData,
|
||||
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
|
||||
).environment(\.revealed, true)
|
||||
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,18 +285,17 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
file: nil
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import SimpleXChat
|
||||
struct CIGroupInvitationView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var groupInvitation: CIGroupInvitation
|
||||
@@ -46,7 +45,7 @@ struct CIGroupInvitationView: View {
|
||||
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
||||
)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
@@ -54,7 +53,7 @@ struct CIGroupInvitationView: View {
|
||||
(
|
||||
groupInvitationText()
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
||||
)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
@@ -70,7 +69,7 @@ struct CIGroupInvitationView: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||
.background(chatItemFrameColor(chatItem, theme))
|
||||
.textSelection(.disabled)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||
.onChange(of: inProgress) { inProgress in
|
||||
|
||||
@@ -165,9 +165,9 @@ struct CIImageView: View {
|
||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.invertedForegroundStyle()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ struct CILinkView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
if let uiImage = imageFromBase64(linkPreview.image) {
|
||||
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
||||
@@ -12,13 +12,11 @@ import SimpleXChat
|
||||
struct CIMetaView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
var chatItem: ChatItem
|
||||
var metaColor: Color
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
var showStatus = true
|
||||
var showEdited = true
|
||||
var invertedMaterial = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||
|
||||
@@ -26,143 +24,93 @@ struct CIMetaView: View {
|
||||
if chatItem.isDeletedContent {
|
||||
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||
} else {
|
||||
ZStack {
|
||||
ciMetaText(
|
||||
chatItem.meta,
|
||||
chatTTL: chat.chatInfo.timedMessagesTTL,
|
||||
encrypted: chatItem.encryptedFile,
|
||||
color: metaColor,
|
||||
paleColor: paleMetaColor,
|
||||
colorMode: invertedMaterial
|
||||
? .invertedMaterial
|
||||
: .normal,
|
||||
showStatus: showStatus,
|
||||
showEdited: showEdited,
|
||||
showViaProxy: showSentViaProxy,
|
||||
showTimesamp: showTimestamp
|
||||
).invertedForegroundStyle(enabled: invertedMaterial)
|
||||
if invertedMaterial {
|
||||
ciMetaText(
|
||||
chatItem.meta,
|
||||
chatTTL: chat.chatInfo.timedMessagesTTL,
|
||||
encrypted: chatItem.encryptedFile,
|
||||
colorMode: .normal,
|
||||
onlyOverrides: true,
|
||||
showStatus: showStatus,
|
||||
showEdited: showEdited,
|
||||
showViaProxy: showSentViaProxy,
|
||||
showTimesamp: showTimestamp
|
||||
)
|
||||
let meta = chatItem.meta
|
||||
let ttl = chat.chatInfo.timedMessagesTTL
|
||||
let encrypted = chatItem.encryptedFile
|
||||
switch meta.itemStatus {
|
||||
case let .sndSent(sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
}
|
||||
case let .sndRcvd(_, sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
}
|
||||
case .partial:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
}
|
||||
}
|
||||
default:
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MetaColorMode {
|
||||
// Renders provided colours
|
||||
case normal
|
||||
// Fully transparent meta - used for reserving space
|
||||
case transparent
|
||||
// Renders white on dark backgrounds and black on light ones
|
||||
case invertedMaterial
|
||||
|
||||
func resolve(_ c: Color?) -> Color? {
|
||||
switch self {
|
||||
case .normal: c
|
||||
case .transparent: .clear
|
||||
case .invertedMaterial: nil
|
||||
}
|
||||
}
|
||||
|
||||
func statusSpacer(_ sent: Bool) -> Text {
|
||||
switch self {
|
||||
case .normal, .transparent:
|
||||
Text(
|
||||
sent
|
||||
? Image("checkmark.wide")
|
||||
: Image(systemName: "circlebadge.fill")
|
||||
).foregroundColor(.clear)
|
||||
case .invertedMaterial: Text(" ").kerning(13)
|
||||
}
|
||||
}
|
||||
enum SentCheckmark {
|
||||
case sent
|
||||
case rcvd1
|
||||
case rcvd2
|
||||
}
|
||||
|
||||
func ciMetaText(
|
||||
_ meta: CIMeta,
|
||||
chatTTL: Int?,
|
||||
encrypted: Bool?,
|
||||
color: Color = .clear, // we use this function to reserve space without rendering meta
|
||||
paleColor: Color? = nil,
|
||||
color: Color = .clear,
|
||||
primaryColor: Color = .accentColor,
|
||||
colorMode: MetaColorMode = .normal,
|
||||
onlyOverrides: Bool = false, // only render colors that differ from base
|
||||
transparent: Bool = false,
|
||||
sent: SentCheckmark? = nil,
|
||||
showStatus: Bool = true,
|
||||
showEdited: Bool = true,
|
||||
showViaProxy: Bool,
|
||||
showTimesamp: Bool
|
||||
showViaProxy: Bool
|
||||
) -> Text {
|
||||
var r = Text("")
|
||||
var space: Text? = nil
|
||||
let appendSpace = {
|
||||
if let sp = space {
|
||||
r = r + sp
|
||||
space = nil
|
||||
}
|
||||
}
|
||||
let resolved = colorMode.resolve(color)
|
||||
if showEdited, meta.itemEdited {
|
||||
r = r + statusIconText("pencil", resolved)
|
||||
r = r + statusIconText("pencil", color)
|
||||
}
|
||||
if meta.disappearing {
|
||||
r = r + statusIconText("timer", resolved).font(.caption2)
|
||||
r = r + statusIconText("timer", color).font(.caption2)
|
||||
let ttl = meta.itemTimed?.ttl
|
||||
if ttl != chatTTL {
|
||||
r = r + colored(Text(shortTimeText(ttl)), resolved)
|
||||
r = r + Text(shortTimeText(ttl)).foregroundColor(color)
|
||||
}
|
||||
space = Text(" ")
|
||||
r = r + Text(" ")
|
||||
}
|
||||
if showViaProxy, meta.sentViaProxy == true {
|
||||
appendSpace()
|
||||
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
|
||||
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
||||
}
|
||||
if showStatus {
|
||||
appendSpace()
|
||||
if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) {
|
||||
let metaColor = if onlyOverrides && statusColor == color {
|
||||
Color.clear
|
||||
} else {
|
||||
colorMode.resolve(statusColor)
|
||||
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
|
||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
||||
let gap = Text(" ").kerning(-1.25)
|
||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||
switch sent {
|
||||
case nil: r = r + t1
|
||||
case .sent: r = r + t1 + gap
|
||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
|
||||
case .rcvd2: r = r + gap + t1
|
||||
}
|
||||
r = r + colored(Text(image), metaColor)
|
||||
r = r + Text(" ")
|
||||
} else if !meta.disappearing {
|
||||
r = r + colorMode.statusSpacer(meta.itemStatus.sent)
|
||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
||||
}
|
||||
space = Text(" ")
|
||||
}
|
||||
if let enc = encrypted {
|
||||
appendSpace()
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
|
||||
space = Text(" ")
|
||||
}
|
||||
if showTimesamp {
|
||||
appendSpace()
|
||||
r = r + colored(meta.timestampText, resolved)
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
||||
}
|
||||
r = r + meta.timestampText.foregroundColor(color)
|
||||
return r.font(.caption)
|
||||
}
|
||||
|
||||
private func statusIconText(_ icon: String, _ color: Color?) -> Text {
|
||||
colored(Text(Image(systemName: icon)), color)
|
||||
}
|
||||
|
||||
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
|
||||
private func colored(_ t: Text, _ color: Color?) -> Text {
|
||||
if let color {
|
||||
t.foregroundColor(color)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
Text(Image(systemName: icon)).foregroundColor(color)
|
||||
}
|
||||
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
|
||||
@@ -15,7 +15,6 @@ struct CIRcvDecryptionError: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
var msgDecryptError: MsgDecryptError
|
||||
var msgCount: UInt32
|
||||
var chatItem: ChatItem
|
||||
@@ -48,7 +47,7 @@ struct CIRcvDecryptionError: View {
|
||||
if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||
do {
|
||||
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
|
||||
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
|
||||
if let s = stats {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, s)
|
||||
}
|
||||
@@ -69,40 +68,37 @@ struct CIRcvDecryptionError: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
Group {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn?.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
}
|
||||
} else if !contactStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedContactAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn?.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
}
|
||||
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||
let mem = m.getGroupMember(groupMember.groupMemberId),
|
||||
let memberStats = mem.wrapped.activeConn?.connectionStats {
|
||||
if memberStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
||||
}
|
||||
} else if !memberStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedMemberAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
} else if !contactStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedContactAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||
let mem = m.getGroupMember(groupMember.groupMemberId),
|
||||
let memberStats = mem.wrapped.activeConn?.connectionStats {
|
||||
if memberStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
||||
}
|
||||
} else if !memberStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedMemberAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||
}
|
||||
|
||||
private func basicDecryptionErrorItem() -> some View {
|
||||
@@ -126,7 +122,7 @@ struct CIRcvDecryptionError: View {
|
||||
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
@@ -135,6 +131,7 @@ struct CIRcvDecryptionError: View {
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
@@ -145,7 +142,7 @@ struct CIRcvDecryptionError: View {
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||
@@ -153,6 +150,7 @@ struct CIRcvDecryptionError: View {
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -292,20 +292,28 @@ struct CIVideoView: View {
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private var fileSizeString: String {
|
||||
if let file = chatItem.file, !videoPlaying {
|
||||
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private func durationProgress() -> some View {
|
||||
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
|
||||
.invertedForegroundStyle()
|
||||
HStack {
|
||||
Text("\(durationText(videoPlaying ? progress : duration))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding([.top, .leading], 6)
|
||||
|
||||
if let file = chatItem.file, !videoPlaying {
|
||||
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
@@ -403,9 +411,9 @@ struct CIVideoView: View {
|
||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.invertedForegroundStyle()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(smallView ? 0 : padding)
|
||||
}
|
||||
|
||||
@@ -420,8 +428,10 @@ struct CIVideoView: View {
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(progress) / Double(total))
|
||||
.stroke(style: StrokeStyle(lineWidth: 2))
|
||||
.invertedForegroundStyle()
|
||||
.stroke(
|
||||
Color(uiColor: .white),
|
||||
style: StrokeStyle(lineWidth: 2)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||
|
||||
@@ -510,10 +510,10 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
duration: 30,
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
|
||||
@@ -92,13 +92,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||
ChatItemView(chat: Chat.sampleData, 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."))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, 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))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ import AVKit
|
||||
|
||||
struct FullScreenMediaView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var scrollModel: ReverseListScrollModel
|
||||
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
|
||||
@State var chatItem: ChatItem
|
||||
@State var image: UIImage?
|
||||
@State var player: AVPlayer? = nil
|
||||
|
||||
@@ -69,7 +69,7 @@ struct CIMsgError: View {
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture(perform: onTap)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import SimpleXChat
|
||||
struct MarkedDeletedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.revealed) var revealed: Bool
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
|
||||
var body: some View {
|
||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
||||
@@ -22,7 +22,7 @@ struct MarkedDeletedItemView: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||
.background(chatItemFrameColor(chatItem, theme))
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
@@ -79,10 +79,7 @@ struct MarkedDeletedItemView: View {
|
||||
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
MarkedDeletedItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
|
||||
).environment(\.revealed, true)
|
||||
MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ private func typing(_ w: Font.Weight = .light) -> Text {
|
||||
|
||||
struct MsgContentView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var text: String
|
||||
var formattedText: [FormattedText]? = nil
|
||||
@@ -85,7 +84,7 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ struct ChatItemForwardingView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var chatItems: [ChatItem]
|
||||
var ci: ChatItem
|
||||
var fromChatInfo: ChatInfo
|
||||
@Binding var composeState: ComposeState
|
||||
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocused
|
||||
@State private var alert: SomeAlert?
|
||||
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
|
||||
|
||||
@@ -45,6 +46,8 @@ struct ChatItemForwardingView: View {
|
||||
VStack(alignment: .leading) {
|
||||
if !chatsToForwardTo.isEmpty {
|
||||
List {
|
||||
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
|
||||
.padding(.leading, 2)
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
|
||||
ForEach(chats) { chat in
|
||||
@@ -52,7 +55,6 @@ struct ChatItemForwardingView: View {
|
||||
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} else {
|
||||
ZStack {
|
||||
@@ -71,14 +73,11 @@ struct ChatItemForwardingView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
|
||||
let prohibited = chatItems.map { ci in
|
||||
chat.prohibitedByPref(
|
||||
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
|
||||
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
|
||||
isVoice: ci.content.msgContent?.isVoice ?? false
|
||||
)
|
||||
}.contains(true)
|
||||
|
||||
let prohibited = chat.prohibitedByPref(
|
||||
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
|
||||
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
|
||||
isVoice: ci.content.msgContent?.isVoice ?? false
|
||||
)
|
||||
Button {
|
||||
if prohibited {
|
||||
alert = SomeAlert(
|
||||
@@ -94,10 +93,10 @@ struct ChatItemForwardingView: View {
|
||||
composeState = ComposeState(
|
||||
message: composeState.message,
|
||||
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
|
||||
contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
|
||||
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
|
||||
)
|
||||
} else {
|
||||
composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
|
||||
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
||||
ItemsModel.shared.loadOpenChat(chat.id)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +123,7 @@ struct ChatItemForwardingView: View {
|
||||
|
||||
#Preview {
|
||||
ChatItemForwardingView(
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
|
||||
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||
fromChatInfo: .direct(contact: Contact.sampleData),
|
||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||
).environmentObject(CurrentColors.toAppTheme())
|
||||
|
||||
@@ -450,8 +450,20 @@ struct ChatItemInfoView: View {
|
||||
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
||||
}
|
||||
let v = Group {
|
||||
let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
|
||||
image.foregroundColor(statusColor)
|
||||
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
|
||||
switch status {
|
||||
case .rcvd:
|
||||
ZStack(alignment: .trailing) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
.padding(.trailing, 6)
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
}
|
||||
default:
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
}
|
||||
|
||||
if let (title, text) = status.statusInfo {
|
||||
|
||||
@@ -9,33 +9,12 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
extension EnvironmentValues {
|
||||
struct ShowTimestamp: EnvironmentKey {
|
||||
static let defaultValue: Bool = true
|
||||
}
|
||||
|
||||
struct Revealed: EnvironmentKey {
|
||||
static let defaultValue: Bool = true
|
||||
}
|
||||
|
||||
var showTimestamp: Bool {
|
||||
get { self[ShowTimestamp.self] }
|
||||
set { self[ShowTimestamp.self] = newValue }
|
||||
}
|
||||
|
||||
var revealed: Bool {
|
||||
get { self[Revealed.self] }
|
||||
set { self[Revealed.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@Environment(\.revealed) var revealed: Bool
|
||||
var chatItem: ChatItem
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@Binding var revealed: Bool
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
init(
|
||||
@@ -43,25 +22,27 @@ struct ChatItemView: View {
|
||||
chatItem: ChatItem,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
revealed: Binding<Bool>,
|
||||
allowMenu: Binding<Bool> = .constant(false)
|
||||
) {
|
||||
self.chat = chat
|
||||
self.chatItem = chatItem
|
||||
self.maxWidth = maxWidth
|
||||
_revealed = revealed
|
||||
_allowMenu = allowMenu
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let ci = chatItem
|
||||
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
|
||||
MarkedDeletedItemView(chat: chat, chatItem: chatItem)
|
||||
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
|
||||
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chat: chat, chatItem: ci)
|
||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
|
||||
} else if ci.content.msgContent == nil {
|
||||
ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
} else {
|
||||
framedItemView()
|
||||
}
|
||||
@@ -79,7 +60,7 @@ struct ChatItemView: View {
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
.flatMap { imageFromBase64($0) }
|
||||
.flatMap { UIImage(base64Encoded: $0) }
|
||||
let adjustedMaxWidth = {
|
||||
if let preview, preview.size.width <= preview.size.height {
|
||||
maxWidth * 0.75
|
||||
@@ -91,6 +72,7 @@ struct ChatItemView: View {
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
preview: preview,
|
||||
revealed: $revealed,
|
||||
maxWidth: maxWidth,
|
||||
imgWidth: adjustedMaxWidth,
|
||||
videoWidth: adjustedMaxWidth,
|
||||
@@ -102,9 +84,9 @@ struct ChatItemView: View {
|
||||
struct ChatItemContentView<Content: View>: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.revealed) var revealed: Bool
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var msgContentView: () -> Content
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
@@ -136,7 +118,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case let .rcvChatPreference(feature, allowed, param):
|
||||
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||
case let .sndChatPreference(feature, _, _):
|
||||
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
|
||||
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
|
||||
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||
@@ -183,7 +165,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
}
|
||||
|
||||
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
|
||||
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
|
||||
}
|
||||
|
||||
private var mergedGroupEventText: Text? {
|
||||
@@ -244,17 +226,16 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
||||
struct ChatItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
}
|
||||
.environment(\.revealed, false)
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
@@ -272,7 +253,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
@@ -282,7 +264,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
@@ -292,7 +275,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
@@ -302,7 +286,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
@@ -312,10 +297,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
content: ciFeatureContent,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
),
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
}
|
||||
.environment(\.revealed, true)
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ struct ChatView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State @ObservedObject var chat: Chat
|
||||
@StateObject private var scrollModel = ReverseListScrollModel()
|
||||
@StateObject private var scrollModel = ReverseListScrollModel<ChatItem>()
|
||||
@StateObject private var floatingButtonModel = FloatingButtonModel()
|
||||
@State private var showChatInfoSheet: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
@@ -42,7 +43,6 @@ struct ChatView: View {
|
||||
@State private var showGroupLinkSheet: Bool = false
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@State private var forwardedChatItems: [ChatItem] = []
|
||||
@State private var selectedChatItems: Set<Int64>? = nil
|
||||
@State private var showDeleteSelectedMessages: Bool = false
|
||||
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
|
||||
@@ -53,6 +53,7 @@ struct ChatView: View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.keyboardPadding()
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
} else {
|
||||
viewBody
|
||||
@@ -76,7 +77,7 @@ struct ChatView: View {
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
chatItemsList()
|
||||
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
|
||||
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
|
||||
}
|
||||
connectingText()
|
||||
if selectedChatItems == nil {
|
||||
@@ -99,8 +100,7 @@ struct ChatView: View {
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
showModerateSelectedMessagesAlert(groupInfo)
|
||||
}
|
||||
},
|
||||
forwardItems: forwardSelectedMessages
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,30 +137,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field
|
||||
.appSheet(isPresented: $showAddMembersSheet) {
|
||||
Group {
|
||||
if case let .group(groupInfo) = cInfo {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { !forwardedChatItems.isEmpty },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
forwardedChatItems = []
|
||||
selectedChatItems = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if #available(iOS 16.0, *) {
|
||||
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
|
||||
.presentationDetents([.fraction(0.8)])
|
||||
} else {
|
||||
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedChatItems = nil
|
||||
initChatView()
|
||||
@@ -312,6 +288,9 @@ struct ChatView: View {
|
||||
}
|
||||
} else {
|
||||
addMembersButton()
|
||||
.appSheet(isPresented: $showAddMembersSheet) {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
@@ -362,7 +341,6 @@ struct ChatView: View {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
@@ -434,9 +412,14 @@ struct ChatView: View {
|
||||
composeState: $composeState,
|
||||
selectedMember: $selectedMember,
|
||||
revealedChatItem: $revealedChatItem,
|
||||
selectedChatItems: $selectedChatItems,
|
||||
forwardedChatItems: $forwardedChatItems
|
||||
selectedChatItems: $selectedChatItems
|
||||
)
|
||||
.onAppear {
|
||||
floatingButtonModel.appeared(viewId: ci.viewId)
|
||||
}
|
||||
.onDisappear {
|
||||
floatingButtonModel.disappeared(viewId: ci.viewId)
|
||||
}
|
||||
.id(ci.id) // Required to trigger `onAppear` on iOS15
|
||||
} loadPage: {
|
||||
loadChatItems(cInfo)
|
||||
@@ -447,10 +430,13 @@ struct ChatView: View {
|
||||
.onChange(of: searchText) { _ in
|
||||
Task { await loadChat(chat: chat, search: searchText) }
|
||||
}
|
||||
.onChange(of: im.reversedChatItems) { _ in
|
||||
floatingButtonModel.chatItemsChanged()
|
||||
}
|
||||
.onChange(of: im.itemAdded) { added in
|
||||
if added {
|
||||
im.itemAdded = false
|
||||
if FloatingButtonModel.shared.isReallyNearBottom {
|
||||
if floatingButtonModel.unreadChatItemCounts.isReallyNearBottom {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
}
|
||||
@@ -473,169 +459,103 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
class FloatingButtonModel: ObservableObject {
|
||||
static let shared = FloatingButtonModel()
|
||||
@Published var unreadBelow: Int = 0
|
||||
@Published var isNearBottom: Bool = true
|
||||
@Published var date: Date?
|
||||
@Published var isDateVisible: Bool = false
|
||||
var totalUnread: Int = 0
|
||||
var isReallyNearBottom: Bool = true
|
||||
var hideDateWorkItem: DispatchWorkItem?
|
||||
|
||||
func updateOnListChange(_ listState: ListState) {
|
||||
let im = ItemsModel.shared
|
||||
let unreadBelow =
|
||||
if let id = listState.bottomItemId,
|
||||
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
|
||||
{
|
||||
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
|
||||
if chatItem.isRcvNew { unread += 1 }
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
let date: Date? =
|
||||
if let topItemDate = listState.topItemDate {
|
||||
Calendar.current.startOfDay(for: topItemDate)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
// set the counters and date indicator
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let it = self else { return }
|
||||
it.setDate(visibility: true)
|
||||
it.unreadBelow = unreadBelow
|
||||
it.date = date
|
||||
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
|
||||
}
|
||||
|
||||
// set floating button indication mode
|
||||
let nearBottom = listState.scrollOffset < 800
|
||||
if nearBottom != self.isNearBottom {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
||||
self?.isNearBottom = nearBottom
|
||||
}
|
||||
}
|
||||
|
||||
// hide Date indicator after 1 second of no scrolling
|
||||
hideDateWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let it = self else { return }
|
||||
it.setDate(visibility: false)
|
||||
it.hideDateWorkItem = nil
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.hideDateWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
|
||||
}
|
||||
private enum Event {
|
||||
case appeared(String)
|
||||
case disappeared(String)
|
||||
case chatItemsChanged
|
||||
}
|
||||
|
||||
func resetDate() {
|
||||
date = nil
|
||||
isDateVisible = false
|
||||
}
|
||||
@Published var unreadChatItemCounts: UnreadChatItemCounts
|
||||
|
||||
private func setDate(visibility isVisible: Bool) {
|
||||
if isVisible {
|
||||
if !isNearBottom,
|
||||
!isDateVisible,
|
||||
let date, !Calendar.current.isDateInToday(date) {
|
||||
withAnimation { self.isDateVisible = true }
|
||||
}
|
||||
} else if isDateVisible {
|
||||
withAnimation { self.isDateVisible = false }
|
||||
}
|
||||
}
|
||||
private let events = PassthroughSubject<Event, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
}
|
||||
|
||||
private struct FloatingButtons: View {
|
||||
let theme: AppTheme
|
||||
let scrollModel: ReverseListScrollModel
|
||||
let chat: Chat
|
||||
@ObservedObject var model = FloatingButtonModel.shared
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
if let date = model.date {
|
||||
DateSeparator(date: date)
|
||||
.padding(.vertical, 4).padding(.horizontal, 8)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(Capsule())
|
||||
.opacity(model.isDateVisible ? 1 : 0)
|
||||
}
|
||||
VStack {
|
||||
let unreadAbove = model.totalUnread - model.unreadBelow
|
||||
if unreadAbove > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadAbove)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToNextPage()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await markChatRead(chat)
|
||||
}
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if model.unreadBelow > 0 {
|
||||
circleButton {
|
||||
unreadCountText(model.unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
} else if !model.isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture { scrollModel.scrollToBottom() }
|
||||
init() {
|
||||
unreadChatItemCounts = UnreadChatItemCounts(
|
||||
isNearBottom: true,
|
||||
isReallyNearBottom: true,
|
||||
unreadBelow: 0
|
||||
)
|
||||
events
|
||||
.receive(on: DispatchQueue.global(qos: .background))
|
||||
.scan(Set<String>()) { itemsInView, event in
|
||||
var updated = itemsInView
|
||||
switch event {
|
||||
case let .appeared(viewId): updated.insert(viewId)
|
||||
case let .disappeared(viewId): updated.remove(viewId)
|
||||
case .chatItemsChanged: ()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.onDisappear(perform: model.resetDate)
|
||||
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) }
|
||||
.removeDuplicates()
|
||||
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
|
||||
.assign(to: \.unreadChatItemCounts, on: self)
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.frame(width: 44, height: 44)
|
||||
content()
|
||||
}
|
||||
func appeared(viewId: String) {
|
||||
events.send(.appeared(viewId))
|
||||
}
|
||||
|
||||
func disappeared(viewId: String) {
|
||||
events.send(.disappeared(viewId))
|
||||
}
|
||||
|
||||
func chatItemsChanged() {
|
||||
events.send(.chatItemsChanged)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DateSeparator: View {
|
||||
let date: Date
|
||||
private func floatingButtons(counts: UnreadChatItemCounts) -> some View {
|
||||
VStack {
|
||||
let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
|
||||
if unreadAbove > 0 {
|
||||
circleButton {
|
||||
unreadCountText(unreadAbove)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToNextPage()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
Task {
|
||||
await markChatRead(chat)
|
||||
}
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if counts.unreadBelow > 0 {
|
||||
circleButton {
|
||||
unreadCountText(counts.unreadBelow)
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
} else if !counts.isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture { scrollModel.scrollToBottom() }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
|
||||
date.formatted(.dateTime.weekday(.abbreviated)),
|
||||
date.formatted(
|
||||
Calendar.current.isDate(date, equalTo: .now, toGranularity: .year)
|
||||
? .dateTime.day().month(.abbreviated)
|
||||
: .dateTime.day().month(.abbreviated).year()
|
||||
)
|
||||
))
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.frame(width: 44, height: 44)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,8 +569,8 @@ struct ChatView: View {
|
||||
|
||||
private func endCallButton(_ call: Call) -> some View {
|
||||
Button {
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID {
|
||||
CallController.shared.endCall(callUUID: callUUID)
|
||||
if let uuid = call.callkitUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
} else {
|
||||
CallController.shared.endCall(call: call) {}
|
||||
}
|
||||
@@ -729,116 +649,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func forwardSelectedMessages() {
|
||||
Task {
|
||||
do {
|
||||
if let selectedChatItems {
|
||||
let (validItems, confirmation) = try await apiPlanForwardChatItems(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemIds: Array(selectedChatItems)
|
||||
)
|
||||
if let confirmation {
|
||||
if validItems.count > 0 {
|
||||
showAlert(
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("Forward %d message(s)?", comment: "alert title"),
|
||||
validItems.count
|
||||
),
|
||||
message: forwardConfirmationText(confirmation) + "\n" +
|
||||
NSLocalizedString("Forward messages without files?", comment: "alert message")
|
||||
) {
|
||||
switch confirmation {
|
||||
case let .filesNotAccepted(fileIds):
|
||||
[forwardAction(validItems), downloadAction(fileIds), cancelAlertAction]
|
||||
default:
|
||||
[forwardAction(validItems), cancelAlertAction]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showAlert(
|
||||
NSLocalizedString("Nothing to forward!", comment: "alert title"),
|
||||
message: forwardConfirmationText(confirmation)
|
||||
) {
|
||||
switch confirmation {
|
||||
case let .filesNotAccepted(fileIds):
|
||||
[downloadAction(fileIds), cancelAlertAction]
|
||||
default:
|
||||
[okAlertAction]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await openForwardingSheet(validItems)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("Plan forward chat items failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func forwardConfirmationText(_ fc: ForwardConfirmation) -> String {
|
||||
switch fc {
|
||||
case let .filesNotAccepted(fileIds):
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("%d file(s) were not downloaded.", comment: "forward confirmation reason"),
|
||||
fileIds.count
|
||||
)
|
||||
case let .filesInProgress(filesCount):
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("%d file(s) are still being downloaded.", comment: "forward confirmation reason"),
|
||||
filesCount
|
||||
)
|
||||
case let .filesMissing(filesCount):
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("%d file(s) were deleted.", comment: "forward confirmation reason"),
|
||||
filesCount
|
||||
)
|
||||
case let .filesFailed(filesCount):
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("%d file(s) failed to download.", comment: "forward confirmation reason"),
|
||||
filesCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func forwardAction(_ items: [Int64]) -> UIAlertAction {
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Forward messages", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in Task { await openForwardingSheet(items) } }
|
||||
)
|
||||
}
|
||||
|
||||
func downloadAction(_ fileIds: [Int64]) -> UIAlertAction {
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Download files", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFiles(user: user, fileIds: fileIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func openForwardingSheet(_ items: [Int64]) async {
|
||||
let im = ItemsModel.shared
|
||||
var items = Set(items)
|
||||
var fci = [ChatItem]()
|
||||
for reversedChatItem in im.reversedChatItems {
|
||||
if items.contains(reversedChatItem.id) {
|
||||
items.remove(reversedChatItem.id)
|
||||
fci.insert(reversedChatItem, at: 0)
|
||||
}
|
||||
if items.isEmpty { break }
|
||||
}
|
||||
await MainActor.run { forwardedChatItems = fci }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChatItems(_ cInfo: ChatInfo) {
|
||||
Task {
|
||||
if loadingItems || firstPage { return }
|
||||
@@ -887,7 +697,6 @@ struct ChatView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding @ObservedObject var chat: Chat
|
||||
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
|
||||
let chatItem: ChatItem
|
||||
let maxWidth: CGFloat
|
||||
@Binding var composeState: ComposeState
|
||||
@@ -900,66 +709,39 @@ struct ChatView: View {
|
||||
@State private var showDeleteMessages = false
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
@State private var msgWidth: CGFloat = 0
|
||||
|
||||
@State private var showForwardingSheet: Bool = false
|
||||
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
@Binding var forwardedChatItems: [ChatItem]
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
@State private var markedRead = false
|
||||
|
||||
var revealed: Bool { chatItem == revealedChatItem }
|
||||
|
||||
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
|
||||
|
||||
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
|
||||
let im = ItemsModel.shared
|
||||
if let i, i > 0 && im.reversedChatItems.count >= i {
|
||||
let nextItem = im.reversedChatItems[i - 1]
|
||||
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
|
||||
return (
|
||||
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
|
||||
largeGap: largeGap,
|
||||
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
|
||||
)
|
||||
} else {
|
||||
return (timestamp: true, largeGap: true, date: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let currIndex = m.getChatItemIndex(chatItem)
|
||||
let (currIndex, _) = m.getNextChatItem(chatItem)
|
||||
let ciCategory = chatItem.mergeCategory
|
||||
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
|
||||
let range = itemsRange(currIndex, prevHidden)
|
||||
let timeSeparation = getItemSeparation(chatItem, at: currIndex)
|
||||
let im = ItemsModel.shared
|
||||
Group {
|
||||
if revealed, let range = range {
|
||||
let items = Array(zip(Array(range), im.reversedChatItems[range]))
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
|
||||
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
|
||||
chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let checked = selected.contains(ci.id)
|
||||
selectUnselectChatItem(select: !checked, ci)
|
||||
}
|
||||
ForEach(items.reversed(), id: \.1.viewId) { (i, ci) in
|
||||
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
|
||||
chatItemView(ci, nil, prev)
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let checked = selected.contains(ci.id)
|
||||
selectUnselectChatItem(select: !checked, ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
chatItemView(chatItem, range, prevItem, timeSeparation)
|
||||
if let date = timeSeparation.date {
|
||||
DateSeparator(date: date).padding(8)
|
||||
}
|
||||
}
|
||||
chatItemView(chatItem, range, prevItem)
|
||||
.overlay {
|
||||
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
|
||||
Color.clear
|
||||
@@ -973,16 +755,12 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if markedRead {
|
||||
return
|
||||
} else {
|
||||
markedRead = true
|
||||
}
|
||||
if let range {
|
||||
let itemIds = unreadItemIds(range)
|
||||
if !itemIds.isEmpty {
|
||||
if let items = unreadItems(range) {
|
||||
waitToMarkRead {
|
||||
await apiMarkChatItemsRead(chat.chatInfo, itemIds)
|
||||
for ci in items {
|
||||
await apiMarkChatItemRead(chat.chatInfo, ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if chatItem.isRcvNew {
|
||||
@@ -992,75 +770,29 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
|
||||
|
||||
private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? {
|
||||
let im = ItemsModel.shared
|
||||
return range.compactMap { i in
|
||||
let items = range.compactMap { i in
|
||||
if i >= 0 && i < im.reversedChatItems.count {
|
||||
let ci = im.reversedChatItems[i]
|
||||
return if ci.isRcvNew { ci.id } else { nil }
|
||||
return if ci.isRcvNew { ci } else { nil }
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return if items.isEmpty { nil } else { items }
|
||||
}
|
||||
|
||||
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
|
||||
Task {
|
||||
_ = try? await Task.sleep(nanoseconds: 600_000000)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
await op()
|
||||
Task(operation: op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct MemberLayout: Layout {
|
||||
let spacing: Double
|
||||
let msgWidth: Double
|
||||
|
||||
private func sizes(subviews: Subviews, proposal: ProposedViewSize) -> (CGSize, CGSize) {
|
||||
assert(subviews.count == 2, "member layout must contain exactly two subviews")
|
||||
let roleSize = subviews[1].sizeThatFits(proposal)
|
||||
let memberSize = subviews[0].sizeThatFits(
|
||||
ProposedViewSize(
|
||||
width: (proposal.width ?? msgWidth) - roleSize.width,
|
||||
height: proposal.height
|
||||
)
|
||||
)
|
||||
return (memberSize, roleSize)
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||||
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
|
||||
return CGSize(
|
||||
width: min(
|
||||
proposal.width ?? msgWidth,
|
||||
max(msgWidth, roleSize.width + spacing + memberSize.width)
|
||||
),
|
||||
height: max(memberSize.height, roleSize.height)
|
||||
)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
||||
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
|
||||
subviews[0].place(
|
||||
at: CGPoint(x: bounds.minX, y: bounds.midY - memberSize.height / 2),
|
||||
proposal: ProposedViewSize(memberSize)
|
||||
)
|
||||
subviews[1].place(
|
||||
at: CGPoint(
|
||||
x: bounds.minX + max(memberSize.width + spacing, msgWidth - roleSize.width),
|
||||
y: bounds.midY - roleSize.height / 2
|
||||
),
|
||||
proposal: ProposedViewSize(roleSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View {
|
||||
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
|
||||
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View {
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo) = chat.chatInfo {
|
||||
let (prevMember, memCount): (GroupMember?, Int) =
|
||||
@@ -1072,49 +804,24 @@ struct ChatView: View {
|
||||
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Group {
|
||||
if memCount == 1 && member.memberRole > .member {
|
||||
Group {
|
||||
if #available(iOS 16.0, *) {
|
||||
MemberLayout(spacing: 16, msgWidth: msgWidth) {
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
Text(member.memberRole.text)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 16) {
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
Text(member.memberRole.text)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
maxWidth: maxWidth,
|
||||
alignment: chatItem.chatDir.sent ? .trailing : .leading
|
||||
)
|
||||
} else {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
.lineLimit(2)
|
||||
}
|
||||
let t = if memCount == 1 && member.memberRole > .member {
|
||||
Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName)
|
||||
} else {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
}
|
||||
t
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
|
||||
.padding(.top, 3) // this is in addition to message sequence gap
|
||||
.padding(.top, 7)
|
||||
}
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
if selectedChatItems != nil && ci.canBeDeletedForSelf {
|
||||
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
|
||||
.onTapGesture {
|
||||
if let member = m.getGroupMember(member.groupMemberId) {
|
||||
@@ -1127,12 +834,11 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, bottomPadding)
|
||||
.padding(.bottom, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
@@ -1141,11 +847,11 @@ struct ChatView: View {
|
||||
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
|
||||
.padding(.leading, 12)
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 10 + memberImageSize + 12)
|
||||
.padding(.leading, memberImageSize + 8 + 12)
|
||||
}
|
||||
.padding(.bottom, bottomPadding)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
@@ -1158,10 +864,10 @@ struct ChatView: View {
|
||||
.padding(.leading)
|
||||
}
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom, bottomPadding)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1176,18 +882,17 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
|
||||
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
ChatItemView(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
maxWidth: maxWidth,
|
||||
revealed: .constant(revealed),
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
.environment(\.revealed, revealed)
|
||||
.environment(\.showTimestamp, itemSeparation.timestamp)
|
||||
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
|
||||
.modifier(ChatItemClipped(ci))
|
||||
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
|
||||
.accessibilityLabel("")
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
@@ -1217,6 +922,14 @@ struct ChatView: View {
|
||||
}) {
|
||||
ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
|
||||
}
|
||||
.sheet(isPresented: $showForwardingSheet) {
|
||||
if #available(iOS 16.0, *) {
|
||||
ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
|
||||
.presentationDetents([.fraction(0.8)])
|
||||
} else {
|
||||
ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
@@ -1357,7 +1070,7 @@ struct ChatView: View {
|
||||
|
||||
var forwardButton: Button<some View> {
|
||||
Button {
|
||||
forwardedChatItems = [chatItem]
|
||||
showForwardingSheet = true
|
||||
} label: {
|
||||
Label(
|
||||
NSLocalizedString("Forward", comment: "chat item action"),
|
||||
|
||||
@@ -18,7 +18,7 @@ struct ComposeImageView: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let imgs: [UIImage] = images.compactMap { image in
|
||||
imageFromBase64(image)
|
||||
UIImage(base64Encoded: image)
|
||||
}
|
||||
if imgs.count == 0 {
|
||||
ProgressView()
|
||||
|
||||
@@ -40,7 +40,7 @@ struct ComposeLinkView: View {
|
||||
|
||||
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if let uiImage = imageFromBase64(linkPreview.image) {
|
||||
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
|
||||
@@ -23,7 +23,7 @@ enum ComposeContextItem {
|
||||
case noContextItem
|
||||
case quotedItem(chatItem: ChatItem)
|
||||
case editingItem(chatItem: ChatItem)
|
||||
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
|
||||
case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
|
||||
}
|
||||
|
||||
enum VoiceMessageRecordingState {
|
||||
@@ -73,10 +73,10 @@ struct ComposeState {
|
||||
}
|
||||
}
|
||||
|
||||
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
|
||||
init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
|
||||
self.message = ""
|
||||
self.preview = .noPreview
|
||||
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
|
||||
self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
|
||||
self.voiceMessageRecordingState = .noRecording
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ struct ComposeState {
|
||||
|
||||
var forwarding: Bool {
|
||||
switch contextItem {
|
||||
case .forwardingItems: return true
|
||||
case .forwardingItem: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@@ -167,13 +167,6 @@ struct ComposeState {
|
||||
}
|
||||
}
|
||||
|
||||
var manyMediaPreviews: Bool {
|
||||
switch preview {
|
||||
case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentDisabled: Bool {
|
||||
if editing || forwarding || liveMessage != nil || inProgress { return true }
|
||||
switch preview {
|
||||
@@ -287,8 +280,8 @@ struct ComposeView: View {
|
||||
// this is a workaround to fire an explicit event in certain cases
|
||||
@State private var stopPlayback: Bool = false
|
||||
|
||||
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -389,11 +382,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background {
|
||||
Color.clear
|
||||
.overlay(ToolbarMaterial.material(toolbarMaterial))
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
}
|
||||
.background(ToolbarMaterial.material(toolbarMaterial))
|
||||
.onChange(of: composeState.message) { msg in
|
||||
if composeState.linkPreviewAllowed {
|
||||
if msg.count > 0 {
|
||||
@@ -459,7 +448,7 @@ struct ComposeView: View {
|
||||
Task {
|
||||
var media: [(String, UploadContent)] = []
|
||||
for content in selected {
|
||||
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
media.append((img, content))
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
|
||||
@@ -551,7 +540,7 @@ struct ComposeView: View {
|
||||
}
|
||||
|
||||
private func addMediaContent(_ content: UploadContent) async {
|
||||
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
var newMedia: [(String, UploadContent?)] = []
|
||||
if case var .mediaPreviews(media) = composeState.preview {
|
||||
media.append((img, content))
|
||||
@@ -694,7 +683,7 @@ struct ComposeView: View {
|
||||
case let .quotedItem(chatItem: quotedItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItems: [quotedItem],
|
||||
contextItem: quotedItem,
|
||||
contextIcon: "arrowshape.turn.up.left",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
@@ -702,17 +691,18 @@ struct ComposeView: View {
|
||||
case let .editingItem(chatItem: editingItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItems: [editingItem],
|
||||
contextItem: editingItem,
|
||||
contextIcon: "pencil",
|
||||
cancelContextItem: { clearState() }
|
||||
)
|
||||
Divider()
|
||||
case let .forwardingItems(chatItems, _):
|
||||
case let .forwardingItem(chatItem: forwardedItem, _):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItems: chatItems,
|
||||
contextItem: forwardedItem,
|
||||
contextIcon: "arrowshape.turn.up.forward",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
||||
showSender: false
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
@@ -736,11 +726,10 @@ struct ComposeView: View {
|
||||
}
|
||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||
await sendMemberContactInvitation()
|
||||
} else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
|
||||
// Composed text is send as a reply to the last forwarded item
|
||||
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
|
||||
} else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
|
||||
sent = await forwardItem(ci, fromChatInfo, ttl)
|
||||
if !composeState.message.isEmpty {
|
||||
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
|
||||
sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
|
||||
}
|
||||
} else if case let .editingItem(ci) = composeState.contextItem {
|
||||
sent = await updateMessage(ci, live: live)
|
||||
@@ -757,28 +746,26 @@ struct ComposeView: View {
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
||||
case .linkPreview:
|
||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
|
||||
case let .mediaPreviews(media):
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
let last = media.count - 1
|
||||
var msgs: [ComposedMessage] = []
|
||||
if last >= 0 {
|
||||
for i in 0..<last {
|
||||
if i > 0 {
|
||||
// Sleep to allow `progressByTimeout` update be rendered
|
||||
try? await Task.sleep(nanoseconds: 100_000000)
|
||||
}
|
||||
if let (fileSource, msgContent) = mediaContent(media[i], text: "") {
|
||||
msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent))
|
||||
if case (_, .video(_, _, _)) = media[i] {
|
||||
sent = await sendVideo(media[i], ttl: ttl)
|
||||
} else {
|
||||
sent = await sendImage(media[i], ttl: ttl)
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
||||
}
|
||||
if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) {
|
||||
msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent))
|
||||
if case (_, .video(_, _, _)) = media[last] {
|
||||
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
|
||||
} else {
|
||||
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
|
||||
}
|
||||
}
|
||||
if msgs.isEmpty {
|
||||
msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
|
||||
if sent == nil {
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
||||
}
|
||||
sent = await send(msgs, live: live, ttl: ttl).last
|
||||
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
let file = voiceCryptoFile(recordingFileName)
|
||||
@@ -800,20 +787,6 @@ struct ComposeView: View {
|
||||
}
|
||||
return sent
|
||||
|
||||
func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? {
|
||||
let (previewImage, uploadContent) = media
|
||||
return switch uploadContent {
|
||||
case let .simpleImage(image):
|
||||
(saveImage(image), .image(text: text, image: previewImage))
|
||||
case let .animatedImage(image):
|
||||
(saveAnimImage(image), .image(text: text, image: previewImage))
|
||||
case let .video(_, url, duration):
|
||||
(moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration))
|
||||
case .none:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
func sending() async {
|
||||
await MainActor.run { composeState.inProgress = true }
|
||||
}
|
||||
@@ -877,6 +850,23 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if let data = data, let savedFile = saveAnyImage(data) {
|
||||
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
|
||||
if !privacyEncryptLocalFilesGroupDefault.get() {
|
||||
return CryptoFile.plain(fileName)
|
||||
@@ -893,66 +883,44 @@ struct ComposeView: View {
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
await send(
|
||||
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
|
||||
live: live,
|
||||
ttl: ttl
|
||||
).first
|
||||
}
|
||||
|
||||
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
|
||||
if let chatItems = chat.chatInfo.chatType == .local
|
||||
? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
|
||||
: await apiSendMessages(
|
||||
if let chatItem = chat.chatInfo.chatType == .local
|
||||
? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
|
||||
: await apiSendMessage(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
file: file,
|
||||
quotedItemId: quoted,
|
||||
msg: mc,
|
||||
live: live,
|
||||
ttl: ttl,
|
||||
composedMessages: msgs
|
||||
ttl: ttl
|
||||
) {
|
||||
await MainActor.run {
|
||||
chatModel.removeLiveDummy(animated: false)
|
||||
for chatItem in chatItems {
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItems
|
||||
return chatItem
|
||||
}
|
||||
for msg in msgs {
|
||||
if let file = msg.fileSource {
|
||||
removeFile(file.filePath)
|
||||
}
|
||||
if let file = file {
|
||||
removeFile(file.filePath)
|
||||
}
|
||||
return []
|
||||
return nil
|
||||
}
|
||||
|
||||
func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] {
|
||||
if let chatItems = await apiForwardChatItems(
|
||||
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
|
||||
if let chatItem = await apiForwardChatItem(
|
||||
toChatType: chat.chatInfo.chatType,
|
||||
toChatId: chat.chatInfo.apiId,
|
||||
fromChatType: fromChatInfo.chatType,
|
||||
fromChatId: fromChatInfo.apiId,
|
||||
itemIds: forwardedItems.map { $0.id },
|
||||
itemId: forwardedItem.id,
|
||||
ttl: ttl
|
||||
) {
|
||||
await MainActor.run {
|
||||
for chatItem in chatItems {
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
if forwardedItems.count != chatItems.count {
|
||||
showAlert(
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("%d messages not forwarded", comment: "alert title"),
|
||||
forwardedItems.count - chatItems.count
|
||||
),
|
||||
message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message")
|
||||
)
|
||||
}
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItems
|
||||
} else {
|
||||
return []
|
||||
return chatItem
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkLinkPreview() -> MsgContent {
|
||||
@@ -969,6 +937,14 @@ struct ComposeView: View {
|
||||
return .text(msgText)
|
||||
}
|
||||
}
|
||||
|
||||
func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
|
||||
switch img {
|
||||
case let .simpleImage(image): return saveImage(image)
|
||||
case let .animatedImage(image): return saveAnimImage(image)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startVoiceMessageRecording() async {
|
||||
|
||||
@@ -12,7 +12,7 @@ import SimpleXChat
|
||||
struct ContextItemView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
let contextItems: [ChatItem]
|
||||
let contextItem: ChatItem
|
||||
let contextIcon: String
|
||||
let cancelContextItem: () -> Void
|
||||
var showSender: Bool = true
|
||||
@@ -24,22 +24,13 @@ struct ContextItemView: View {
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if let singleItem = contextItems.first, contextItems.count == 1 {
|
||||
if showSender, let sender = singleItem.memberDisplayName {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
|
||||
msgContentView(lines: 2, contextItem: singleItem)
|
||||
}
|
||||
} else {
|
||||
msgContentView(lines: 3, contextItem: singleItem)
|
||||
}
|
||||
if showSender, let sender = contextItem.memberDisplayName {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
|
||||
msgContentView(lines: 2)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
chat.chatInfo.chatType == .local
|
||||
? "Saving \(contextItems.count) messages"
|
||||
: "Forwarding \(contextItems.count) messages"
|
||||
)
|
||||
.italic()
|
||||
msgContentView(lines: 3)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
@@ -54,32 +45,23 @@ struct ContextItemView: View {
|
||||
.padding(12)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(background)
|
||||
.background(chatItemFrameColor(contextItem, theme))
|
||||
}
|
||||
|
||||
private var background: Color {
|
||||
contextItems.first
|
||||
.map { chatItemFrameColor($0, theme) }
|
||||
?? Color(uiColor: .tertiarySystemBackground)
|
||||
}
|
||||
|
||||
private func msgContentView(lines: Int, contextItem: ChatItem) -> some View {
|
||||
contextMsgPreview(contextItem)
|
||||
private func msgContentView(lines: Int) -> some View {
|
||||
contextMsgPreview()
|
||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||
.lineLimit(lines)
|
||||
}
|
||||
|
||||
private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
|
||||
private func contextMsgPreview() -> Text {
|
||||
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
|
||||
func attachment() -> Text {
|
||||
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
|
||||
FileManager.default.fileExists(atPath: getAppFilePath(fileSource.filePath).path)
|
||||
} else { false }
|
||||
switch contextItem.content.msgContent {
|
||||
case .file: return isFileLoaded ? image("doc.fill") : Text("")
|
||||
case .file: return image("doc.fill")
|
||||
case .image: return image("photo")
|
||||
case .voice: return isFileLoaded ? image("play.fill") : Text("")
|
||||
case .voice: return image("play.fill")
|
||||
default: return Text("")
|
||||
}
|
||||
}
|
||||
@@ -93,6 +75,6 @@ struct ContextItemView: View {
|
||||
struct ContextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ struct SendMessageView: View {
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
|
||||
@State private var progressByTimeout = false
|
||||
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -227,7 +227,6 @@ struct SendMessageView: View {
|
||||
!composeState.editing {
|
||||
if case .noContextItem = composeState.contextItem,
|
||||
!composeState.voicePreview,
|
||||
!composeState.manyMediaPreviews,
|
||||
let send = sendLiveMessage,
|
||||
let update = updateLiveMessage {
|
||||
Button {
|
||||
|
||||
@@ -105,10 +105,8 @@ struct AddGroupMembersViewCommon: View {
|
||||
.padding(.leading, 2)
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||
ForEach(members + [dummyContact]) { contact in
|
||||
if contact.contactId != dummyContact.contactId {
|
||||
contactCheckView(contact)
|
||||
}
|
||||
ForEach(members) { contact in
|
||||
contactCheckView(contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,14 +130,6 @@ struct AddGroupMembersViewCommon: View {
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
// Resolves keyboard losing focus bug in iOS16 and iOS17,
|
||||
// when there are no items inside `ForEach(memebers)` loop
|
||||
private let dummyContact: Contact = {
|
||||
var dummy = Contact.sampleData
|
||||
dummy.contactId = -1
|
||||
return dummy
|
||||
}()
|
||||
|
||||
private func inviteMembersButton() -> some View {
|
||||
Button {
|
||||
inviteMembers()
|
||||
|
||||
@@ -185,6 +185,7 @@ struct GroupChatInfoView: View {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
private func groupInfoHeader() -> some View {
|
||||
|
||||
@@ -18,7 +18,6 @@ struct GroupMemberInfoView: View {
|
||||
var navigation: Bool = false
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@State private var connectionLoaded: Bool = false
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@@ -95,137 +94,129 @@ struct GroupMemberInfoView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
if connectionLoaded {
|
||||
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if groupInfo.membership.memberRole >= .admin {
|
||||
adminDestructiveSection(member)
|
||||
} else {
|
||||
nonAdminBlockSection(member)
|
||||
}
|
||||
if groupInfo.membership.memberRole >= .admin {
|
||||
adminDestructiveSection(member)
|
||||
} else {
|
||||
nonAdminBlockSection(member)
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
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)
|
||||
}
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
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)
|
||||
}
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.task {
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
justOpened = false
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
await MainActor.run {
|
||||
DispatchQueue.main.async {
|
||||
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)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
connectionLoaded = true
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
connectionLoaded = true
|
||||
}
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
|
||||
@@ -110,13 +110,10 @@ struct GroupProfileView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
Task {
|
||||
let resized: String? = if let image {
|
||||
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
await MainActor.run { groupProfile.image = resized }
|
||||
if let image = image {
|
||||
groupProfile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||
} else {
|
||||
groupProfile.image = nil
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@@ -8,16 +8,15 @@
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SimpleXChat
|
||||
|
||||
/// A List, which displays it's items in reverse order - from bottom to top
|
||||
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
let items: Array<ChatItem>
|
||||
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
||||
let items: Array<Item>
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel.State
|
||||
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
||||
|
||||
/// Closure, that returns user interface for a given item
|
||||
let content: (ChatItem) -> Content
|
||||
let content: (Item) -> Content
|
||||
|
||||
let loadPage: () -> Void
|
||||
|
||||
@@ -26,9 +25,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||
controller.representer = self
|
||||
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||
controller.view.layer.removeAllAnimations()
|
||||
switch destination {
|
||||
case .nextPage:
|
||||
controller.scrollToNextPage()
|
||||
@@ -45,10 +42,9 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
/// Controller, which hosts SwiftUI cells
|
||||
class Controller: UITableViewController {
|
||||
private enum Section { case main }
|
||||
var representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||
private let representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||
private var itemCount: Int = 0
|
||||
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
init(representer: ReverseList) {
|
||||
@@ -75,10 +71,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
// 3. Configure data source
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
|
||||
tableView: tableView
|
||||
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||
if indexPath.item > self.itemCount - 8 {
|
||||
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
|
||||
self.representer.loadPage()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
@@ -107,15 +103,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
name: notificationName,
|
||||
object: nil
|
||||
)
|
||||
|
||||
updateFloatingButtons
|
||||
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
|
||||
.sink {
|
||||
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
|
||||
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
|
||||
}
|
||||
}
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -184,8 +171,8 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
func update(items: [ChatItem]) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||
func update(items: Array<Item>) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
dataSource.defaultRowAnimation = .none
|
||||
@@ -193,50 +180,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
snapshot,
|
||||
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||
)
|
||||
// Sets content offset on initial load
|
||||
if itemCount == 0 {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: 0, y: -InvertedTableView.inset),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
itemCount = items.count
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
updateFloatingButtons.send()
|
||||
}
|
||||
|
||||
func getListState() -> ListState? {
|
||||
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||
visibleRows.last?.item ?? 0 < representer.items.count {
|
||||
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
|
||||
let topItemDate: Date? =
|
||||
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[lastVisible.item].meta.itemTs
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let bottomItemId: ChatItem.ID? =
|
||||
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||
representer.items[firstVisible.item].id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isVisible(indexPath: IndexPath) -> Bool {
|
||||
if let relativeFrame = tableView.superview?.convert(
|
||||
tableView.rectForRow(at: indexPath),
|
||||
from: tableView
|
||||
) {
|
||||
relativeFrame.maxY > InvertedTableView.inset &&
|
||||
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||
} else { false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,19 +224,13 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
typealias ListState = (
|
||||
scrollOffset: Double,
|
||||
topItemDate: Date?,
|
||||
bottomItemId: ChatItem.ID?
|
||||
)
|
||||
|
||||
/// Manages ``ReverseList`` scrolling
|
||||
class ReverseListScrollModel: ObservableObject {
|
||||
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
|
||||
/// Represents Scroll State of ``ReverseList``
|
||||
enum State: Equatable {
|
||||
enum Destination: Equatable {
|
||||
case nextPage
|
||||
case item(ChatItem.ID)
|
||||
case item(Item.ID)
|
||||
case bottom
|
||||
}
|
||||
|
||||
@@ -310,7 +248,7 @@ class ReverseListScrollModel: ObservableObject {
|
||||
state = .scrollingTo(.bottom)
|
||||
}
|
||||
|
||||
func scrollToItem(id: ChatItem.ID) {
|
||||
func scrollToItem(id: Item.ID) {
|
||||
state = .scrollingTo(.item(id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,17 +32,13 @@ struct SelectedItemsBottomToolbar: View {
|
||||
var deleteItems: (Bool) -> Void
|
||||
var moderateItems: () -> Void
|
||||
//var shareItems: () -> Void
|
||||
var forwardItems: () -> Void
|
||||
@State var deleteEnabled: Bool = false
|
||||
@State var deleteForEveryoneEnabled: Bool = false
|
||||
|
||||
@State var canModerate: Bool = false
|
||||
@State var moderateEnabled: Bool = false
|
||||
|
||||
@State var forwardEnabled: Bool = false
|
||||
|
||||
@State var deleteCountProhibited = false
|
||||
@State var forwardCountProhibited = false
|
||||
@State var allButtonsDisabled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -54,11 +50,10 @@ struct SelectedItemsBottomToolbar: View {
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
.foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red)
|
||||
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
|
||||
}
|
||||
.disabled(!deleteEnabled || deleteCountProhibited)
|
||||
.disabled(!deleteEnabled || allButtonsDisabled)
|
||||
|
||||
Spacer()
|
||||
Button {
|
||||
@@ -66,24 +61,24 @@ struct SelectedItemsBottomToolbar: View {
|
||||
} label: {
|
||||
Image(systemName: "flag")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
.foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
|
||||
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
|
||||
}
|
||||
.disabled(!moderateEnabled || deleteCountProhibited)
|
||||
.disabled(!moderateEnabled || allButtonsDisabled)
|
||||
.opacity(canModerate ? 1 : 0)
|
||||
|
||||
|
||||
Spacer()
|
||||
Button {
|
||||
forwardItems()
|
||||
//shareItems()
|
||||
} label: {
|
||||
Image(systemName: "arrowshape.turn.up.forward")
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
.foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
|
||||
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
|
||||
}
|
||||
.disabled(!forwardEnabled || forwardCountProhibited)
|
||||
.disabled(allButtonsDisabled)
|
||||
.opacity(0)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding([.leading, .trailing], 12)
|
||||
@@ -106,22 +101,20 @@ struct SelectedItemsBottomToolbar: View {
|
||||
|
||||
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
|
||||
let count = selectedItems?.count ?? 0
|
||||
deleteCountProhibited = count == 0 || count > 200
|
||||
forwardCountProhibited = count == 0 || count > 20
|
||||
allButtonsDisabled = count == 0 || count > 20
|
||||
canModerate = possibleToModerate(chatInfo)
|
||||
if let selected = selectedItems {
|
||||
let me: Bool
|
||||
let onlyOwnGroupItems: Bool
|
||||
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in
|
||||
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
|
||||
if selected.contains(ci.id) {
|
||||
var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
|
||||
var (de, dee, me, onlyOwnGroupItems, sel) = r
|
||||
de = de && ci.canBeDeletedForSelf
|
||||
dee = dee && ci.meta.deletable && !ci.localNote
|
||||
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
|
||||
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
|
||||
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
|
||||
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
|
||||
return (de, dee, me, onlyOwnGroupItems, fe, sel)
|
||||
return (de, dee, me, onlyOwnGroupItems, sel)
|
||||
} else {
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
struct ChatHelp: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
let dismissSettingsSheet: DismissAction
|
||||
@Binding var showSettings: Bool
|
||||
|
||||
var body: some View {
|
||||
ScrollView { chatHelp() }
|
||||
@@ -23,7 +23,7 @@ struct ChatHelp: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("To ask any questions and to receive updates:")
|
||||
Button("connect to SimpleX Chat developers.") {
|
||||
dismissSettingsSheet()
|
||||
showSettings = false
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
@@ -61,9 +61,8 @@ struct ChatHelp: View {
|
||||
}
|
||||
|
||||
struct ChatHelp_Previews: PreviewProvider {
|
||||
@Environment(\.dismiss) static var mockDismiss
|
||||
|
||||
static var previews: some View {
|
||||
ChatHelp(dismissSettingsSheet: mockDismiss)
|
||||
@State var showSettings = false
|
||||
return ChatHelp(showSettings: $showSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +355,6 @@ struct ChatListNavLink: View {
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: dynamicRowHeight)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { showContactRequestDialog = true }
|
||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||
@@ -393,7 +392,6 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showContactConnectionInfo = true
|
||||
}
|
||||
|
||||
@@ -9,87 +9,18 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum UserPickerSheet: Identifiable {
|
||||
case address
|
||||
case chatPreferences
|
||||
case chatProfiles
|
||||
case currentProfile
|
||||
case useFromDesktop
|
||||
case settings
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var navigationTitle: LocalizedStringKey {
|
||||
switch self {
|
||||
case .address: "SimpleX address"
|
||||
case .chatPreferences: "Your preferences"
|
||||
case .chatProfiles: "Your chat profiles"
|
||||
case .currentProfile: "Your current profile"
|
||||
case .useFromDesktop: "Connect to desktop"
|
||||
case .settings: "Your settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UserPickerSheetView: View {
|
||||
let sheet: UserPickerSheet
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
if loaded, let currentUser = chatModel.currentUser {
|
||||
switch sheet {
|
||||
case .address:
|
||||
UserAddressView(shareViaProfile: currentUser.addressShared)
|
||||
case .chatPreferences:
|
||||
PreferencesView(
|
||||
profile: currentUser.profile,
|
||||
preferences: currentUser.fullPreferences,
|
||||
currentPreferences: currentUser.fullPreferences
|
||||
)
|
||||
case .chatProfiles:
|
||||
UserProfilesView()
|
||||
case .currentProfile:
|
||||
UserProfile()
|
||||
case .useFromDesktop:
|
||||
ConnectDesktopView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
Color.clear // Required for list background to be rendered during loading
|
||||
}
|
||||
.navigationTitle(sheet.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.overlay {
|
||||
if let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
withAnimation(
|
||||
.easeOut(duration: 0.1),
|
||||
{ loaded = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var activeUserPickerSheet: UserPickerSheet?
|
||||
@Binding var showSettings: Bool
|
||||
@State private var searchMode = false
|
||||
@FocusState private var searchFocussed
|
||||
@State private var searchText = ""
|
||||
@State private var searchShowingSimplexLink = false
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var userPickerVisible = false
|
||||
@State private var showConnectDesktop = false
|
||||
@State private var scrollToSearchBar = false
|
||||
@State private var userPickerShown: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||
@@ -115,23 +46,21 @@ struct ChatListView: View {
|
||||
),
|
||||
destination: chatView
|
||||
) { chatListView }
|
||||
}
|
||||
.modifier(
|
||||
Sheet(isPresented: $userPickerShown) {
|
||||
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
|
||||
}
|
||||
)
|
||||
.appSheet(
|
||||
item: $activeUserPickerSheet,
|
||||
onDismiss: { chatModel.laRequest = nil },
|
||||
content: { UserPickerSheetView(sheet: $0) }
|
||||
)
|
||||
.onChange(of: activeUserPickerSheet) {
|
||||
if $0 != nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
userPickerShown = false
|
||||
if userPickerVisible {
|
||||
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
UserPicker(
|
||||
showSettings: $showSettings,
|
||||
showConnectDesktop: $showConnectDesktop,
|
||||
userPickerVisible: $userPickerVisible
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showConnectDesktop) {
|
||||
ConnectDesktopView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +73,7 @@ struct ChatListView: View {
|
||||
.navigationBarHidden(searchMode || oneHandUI)
|
||||
}
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.onDisappear() { activeUserPickerSheet = nil }
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.refreshable {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Reconnect servers?"),
|
||||
@@ -235,7 +164,7 @@ struct ChatListView: View {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
|
||||
.padding([.top, .trailing], 3)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
@@ -244,7 +173,13 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
userPickerShown = true
|
||||
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
} else {
|
||||
showSettings = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,31 +217,14 @@ struct ChatListView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.offset(x: -8)
|
||||
} else {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
VStack(spacing: .zero) {
|
||||
Divider()
|
||||
.padding(.leading, 16)
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.background { theme.colors.background } // Hides default list selection colour
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.offset(x: -8)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: chatModel.chatId) { currentChatId in
|
||||
@@ -334,7 +252,7 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadBadge(size: CGFloat = 18) -> some View {
|
||||
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
|
||||
Circle()
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
@@ -406,7 +324,7 @@ struct ChatListView: View {
|
||||
struct SubsStatusIndicator: View {
|
||||
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
||||
@State private var hasSess: Bool = false
|
||||
@State private var task: Task<Void, Never>?
|
||||
@State private var timer: Timer? = nil
|
||||
@State private var showServersSummary = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
@@ -425,10 +343,10 @@ struct SubsStatusIndicator: View {
|
||||
}
|
||||
.disabled(ChatModel.shared.chatRunning != true)
|
||||
.onAppear {
|
||||
startTask()
|
||||
startTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
stopTask()
|
||||
stopTimer()
|
||||
}
|
||||
.appSheet(isPresented: $showServersSummary) {
|
||||
ServersSummaryView()
|
||||
@@ -436,28 +354,25 @@ struct SubsStatusIndicator: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func startTask() {
|
||||
task = Task {
|
||||
while !Task.isCancelled {
|
||||
if AppChatState.shared.value == .active, ChatModel.shared.chatRunning == true {
|
||||
do {
|
||||
let (subs, hasSess) = try await getAgentSubsTotal()
|
||||
await MainActor.run {
|
||||
self.subs = subs
|
||||
self.hasSess = hasSess
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("getSubsTotal error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
if AppChatState.shared.value == .active {
|
||||
getSubsTotal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopTask() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func getSubsTotal() {
|
||||
do {
|
||||
(subs, hasSess) = try getAgentSubsTotal()
|
||||
} catch let error {
|
||||
logger.error("getSubsTotal error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,29 +496,27 @@ func chatStoppedIcon() -> some View {
|
||||
}
|
||||
|
||||
struct ChatListView_Previews: PreviewProvider {
|
||||
@State static var userPickerSheet: UserPickerSheet? = .none
|
||||
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.updateChats([
|
||||
ChatData(
|
||||
chatModel.chats = [
|
||||
Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||
),
|
||||
ChatData(
|
||||
Chat(
|
||||
chatInfo: ChatInfo.sampleData.group,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "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.")]
|
||||
),
|
||||
ChatData(
|
||||
Chat(
|
||||
chatInfo: ChatInfo.sampleData.contactRequest,
|
||||
chatItems: []
|
||||
)
|
||||
|
||||
])
|
||||
]
|
||||
return Group {
|
||||
ChatListView(activeUserPickerSheet: $userPickerSheet)
|
||||
ChatListView(showSettings: Binding.constant(false))
|
||||
.environmentObject(chatModel)
|
||||
ChatListView(activeUserPickerSheet: $userPickerSheet)
|
||||
ChatListView(showSettings: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +35,11 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
|
||||
let chatTs = if let cItem {
|
||||
cItem.meta.itemTs
|
||||
} else {
|
||||
chat.chatInfo.chatTs
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
chatPreviewTitle()
|
||||
Spacer()
|
||||
(formatTimestampText(chatTs))
|
||||
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
|
||||
.font(.subheadline)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@@ -307,7 +302,7 @@ struct ChatPreviewView: View {
|
||||
case let .link(_, preview):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: imageFromBase64(preview.image) ?? UIImage(systemName: "arrow.up.right")!)
|
||||
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
|
||||
@@ -328,13 +323,13 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
case let .image(_, image):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel())
|
||||
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel<ChatItem>())
|
||||
}
|
||||
case let .video(_,image, duration):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel())
|
||||
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel<ChatItem>())
|
||||
}
|
||||
case let .voice(_, duration):
|
||||
smallContentPreviewVoice(size: dynamicMediaSize) {
|
||||
|
||||
@@ -16,6 +16,7 @@ struct ContactConnectionView: View {
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@State private var localAlias = ""
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var showContactConnectionInfo = false
|
||||
|
||||
var body: some View {
|
||||
if case let .contactConnection(conn) = chat.chatInfo {
|
||||
@@ -31,6 +32,7 @@ struct ContactConnectionView: View {
|
||||
.scaledToFill()
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
|
||||
.onTapGesture { showContactConnectionInfo = true }
|
||||
}
|
||||
.frame(width: 63, height: 63)
|
||||
.padding(.leading, 4)
|
||||
@@ -70,6 +72,9 @@ struct ContactConnectionView: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||
ContactConnectionInfo(contactConnection: contactConnection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,18 +407,12 @@ struct ServersSummaryView: View {
|
||||
|
||||
struct SubscriptionStatusIndicatorView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var subs: SMPServerSubs
|
||||
var hasSess: Bool
|
||||
|
||||
var body: some View {
|
||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(
|
||||
online: m.networkInfo.online,
|
||||
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
|
||||
subs: subs,
|
||||
hasSess: hasSess,
|
||||
primaryColor: theme.colors.primary
|
||||
)
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||
if #available(iOS 16.0, *) {
|
||||
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
|
||||
.foregroundColor(color)
|
||||
@@ -431,32 +425,26 @@ struct SubscriptionStatusIndicatorView: View {
|
||||
|
||||
struct SubscriptionStatusPercentageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var subs: SMPServerSubs
|
||||
var hasSess: Bool
|
||||
|
||||
var body: some View {
|
||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(
|
||||
online: m.networkInfo.online,
|
||||
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
|
||||
subs: subs,
|
||||
hasSess: hasSess,
|
||||
primaryColor: theme.colors.primary
|
||||
)
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) {
|
||||
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
|
||||
func roundedToQuarter(_ n: Double) -> Double {
|
||||
n >= 1 ? 1
|
||||
: n <= 0 ? 0
|
||||
: (n * 4).rounded() / 4
|
||||
}
|
||||
|
||||
let activeColor: Color = usesProxy ? .indigo : primaryColor
|
||||
let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
|
||||
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
|
||||
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
|
||||
|
||||
|
||||
@@ -8,275 +8,176 @@ import SimpleXChat
|
||||
|
||||
struct UserPicker: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@Environment(\.scenePhase) private var scenePhase: ScenePhase
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@Binding var userPickerShown: Bool
|
||||
@Binding var activeSheet: UserPickerSheet?
|
||||
@State private var currentUser: Int64?
|
||||
@State private var switchingProfile = false
|
||||
@State private var frameWidth: CGFloat = 0
|
||||
@State private var resetScroll = ResetScrollAction()
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var showConnectDesktop: Bool
|
||||
@Binding var userPickerVisible: Bool
|
||||
@State var scrollViewContentSize: CGSize = .zero
|
||||
@State var disableScrolling: Bool = true
|
||||
private let menuButtonHeight: CGFloat = 68
|
||||
@State var chatViewNameWidth: CGFloat = 0
|
||||
|
||||
// Inset grouped list dimensions
|
||||
private let imageSize: CGFloat = 44
|
||||
private let rowPadding: CGFloat = 16
|
||||
private let rowVerticalPadding: CGFloat = 11
|
||||
private let sectionSpacing: CGFloat = 35
|
||||
private var sectionHorizontalPadding: CGFloat { frameWidth > 375 ? 20 : 16 }
|
||||
private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
|
||||
var body: some View {
|
||||
let otherUsers: [UserInfo] = m.users
|
||||
.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
|
||||
.sorted(using: KeyPathComparator<UserInfo>(\.user.activeOrder, order: .reverse))
|
||||
let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
|
||||
let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
|
||||
let stopped = m.chatRunning != true
|
||||
VStack(spacing: sectionSpacing) {
|
||||
if let user = m.currentUser {
|
||||
StickyScrollView(resetScroll: $resetScroll) {
|
||||
HStack(spacing: rowPadding) {
|
||||
HStack {
|
||||
ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.padding(.trailing, 6)
|
||||
profileName(user).lineLimit(1)
|
||||
}
|
||||
.padding(rowPadding)
|
||||
.frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
|
||||
.modifier(ListRow { activeSheet = .currentProfile })
|
||||
.clipShape(sectionShape)
|
||||
.disabled(stopped)
|
||||
.opacity(stopped ? 0.4 : 1)
|
||||
ForEach(otherUsers) { u in
|
||||
userView(u, size: imageSize)
|
||||
.frame(maxWidth: sectionWidth * 0.618)
|
||||
.fixedSize()
|
||||
.disabled(stopped)
|
||||
.opacity(stopped ? 0.4 : 1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, sectionHorizontalPadding)
|
||||
}
|
||||
.frame(height: 2 * rowPadding + imageSize)
|
||||
.padding(.top, sectionSpacing)
|
||||
.overlay(DetermineWidth())
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||
}
|
||||
VStack {
|
||||
Spacer().frame(height: 1)
|
||||
VStack(spacing: 0) {
|
||||
openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address, disabled: stopped)
|
||||
openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences, disabled: stopped)
|
||||
openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles, disabled: stopped)
|
||||
openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop, disabled: stopped)
|
||||
ZStack(alignment: .trailing) {
|
||||
openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false)
|
||||
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: 20, maxHeight: .infinity)
|
||||
.padding(.horizontal, rowPadding)
|
||||
.background(Color(.systemBackground).opacity(0.01))
|
||||
.onTapGesture {
|
||||
if (colorScheme == .light) {
|
||||
ThemeManager.applyTheme(systemDarkThemeDefault.get())
|
||||
} else {
|
||||
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
|
||||
ScrollView {
|
||||
ScrollViewReader { sp in
|
||||
let users = m.users
|
||||
.filter({ u in u.user.activeUser || !u.user.hidden })
|
||||
.sorted { u, _ in u.user.activeUser }
|
||||
VStack(spacing: 0) {
|
||||
ForEach(users) { u in
|
||||
userView(u)
|
||||
Divider()
|
||||
if u.user.activeUser { Divider() }
|
||||
}
|
||||
}
|
||||
.onLongPressGesture {
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||
.overlay {
|
||||
GeometryReader { geo -> Color in
|
||||
DispatchQueue.main.async {
|
||||
scrollViewContentSize = geo.size
|
||||
let scenes = UIApplication.shared.connectedScenes
|
||||
if let windowScene = scenes.first as? UIWindowScene {
|
||||
let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame
|
||||
disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height
|
||||
}
|
||||
}
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
.onChange(of: userPickerVisible) { visible in
|
||||
if visible, let u = users.first {
|
||||
sp.scrollTo(u.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
|
||||
.frame(maxHeight: scrollViewContentSize.height)
|
||||
|
||||
menuButton("Use from desktop", icon: "desktopcomputer") {
|
||||
showConnectDesktop = true
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
menuButton("Settings", icon: "gearshape") {
|
||||
showSettings = true
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(sectionShape)
|
||||
.padding(.horizontal, sectionHorizontalPadding)
|
||||
.padding(.bottom, sectionSpacing)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.background(
|
||||
Rectangle()
|
||||
.fill(theme.colors.surface)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
|
||||
)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 }
|
||||
.frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300)
|
||||
.padding(8)
|
||||
.opacity(userPickerVisible ? 1.0 : 0.0)
|
||||
.onAppear {
|
||||
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
|
||||
if case .active = scenePhase {
|
||||
currentUser = m.currentUser?.userId
|
||||
do {
|
||||
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
|
||||
if case .active = scenePhase {
|
||||
m.users = try listUsers()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func userView(_ u: UserInfo) -> some View {
|
||||
let user = u.user
|
||||
return Button(action: {
|
||||
if user.activeUser {
|
||||
showSettings = true
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
do {
|
||||
let users = try await listUsersAsync()
|
||||
await MainActor.run {
|
||||
m.users = users
|
||||
currentUser = m.currentUser?.userId
|
||||
}
|
||||
try await changeActiveUserAsync_(user.userId, viewPwd: nil)
|
||||
await MainActor.run { userPickerVisible = false }
|
||||
} catch {
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
await MainActor.run {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error switching profile!",
|
||||
message: "Error: \(responseError(error))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: userPickerShown) {
|
||||
if !$0 { resetScroll() }
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.disabled(switchingProfile)
|
||||
}
|
||||
|
||||
private func userView(_ u: UserInfo, size: CGFloat) -> some View {
|
||||
HStack {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
if (u.unreadCount > 0) {
|
||||
unreadBadge(u).offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 6)
|
||||
Text(u.user.displayName).font(.title2).lineLimit(1)
|
||||
}
|
||||
.padding(rowPadding)
|
||||
.modifier(ListRow {
|
||||
switchingProfile = true
|
||||
Task {
|
||||
do {
|
||||
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
|
||||
await MainActor.run {
|
||||
switchingProfile = false
|
||||
userPickerShown = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
switchingProfile = false
|
||||
showAlert(
|
||||
NSLocalizedString("Error switching profile!", comment: "alertTitle"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
|
||||
)
|
||||
}
|
||||
}, label: {
|
||||
HStack(spacing: 0) {
|
||||
ProfileImage(imageStr: user.image, size: 44, color: Color(uiColor: .tertiarySystemFill))
|
||||
.padding(.trailing, 12)
|
||||
Text(user.chatViewName)
|
||||
.fontWeight(user.activeUser ? .medium : .regular)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.overlay(DetermineWidth())
|
||||
Spacer()
|
||||
if user.activeUser {
|
||||
Image(systemName: "checkmark")
|
||||
} else if u.unreadCount > 0 {
|
||||
unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary)
|
||||
} else if !user.showNtfs {
|
||||
Image(systemName: "speaker.slash")
|
||||
}
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding([.leading, .vertical], 12)
|
||||
})
|
||||
.clipShape(sectionShape)
|
||||
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
|
||||
}
|
||||
|
||||
private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true, disabled: Bool = false) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
settingsRow(icon, color: theme.colors.secondary) {
|
||||
Text(title).foregroundColor(.primary).opacity(disabled ? 0.4 : 1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, rowPadding)
|
||||
.padding(.vertical, rowVerticalPadding)
|
||||
.modifier(ListRow { activeSheet = sheet })
|
||||
.disabled(disabled)
|
||||
if showDivider {
|
||||
Divider().padding(.leading, 52)
|
||||
private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 0) {
|
||||
Text(title)
|
||||
.overlay(DetermineWidth())
|
||||
Spacer()
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 22)
|
||||
.frame(height: menuButtonHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadBadge(_ u: UserInfo) -> some View {
|
||||
let size = dynamicSize(userFont).chatInfoSize
|
||||
return unreadCountText(u.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
||||
.frame(minWidth: size, minHeight: size)
|
||||
.background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
|
||||
}
|
||||
}
|
||||
|
||||
struct ListRow: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@State private var touchDown = false
|
||||
let action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
elevatedSecondarySystemGroupedBackground
|
||||
Color(.systemGray4).opacity(touchDown ? 1 : 0)
|
||||
content
|
||||
TouchOverlay(touchDown: $touchDown, action: action)
|
||||
}
|
||||
}
|
||||
|
||||
var elevatedSecondarySystemGroupedBackground: Color {
|
||||
switch colorScheme {
|
||||
case .dark: Color(0xFF2C2C2E)
|
||||
default: Color(0xFFFFFFFF)
|
||||
}
|
||||
}
|
||||
|
||||
struct TouchOverlay: UIViewRepresentable {
|
||||
@Binding var touchDown: Bool
|
||||
let action: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> TouchView {
|
||||
let touchView = TouchView()
|
||||
let gesture = UILongPressGestureRecognizer(
|
||||
target: touchView,
|
||||
action: #selector(touchView.longPress(gesture:))
|
||||
)
|
||||
gesture.delegate = touchView
|
||||
gesture.minimumPressDuration = 0
|
||||
touchView.addGestureRecognizer(gesture)
|
||||
return touchView
|
||||
}
|
||||
|
||||
func updateUIView(_ touchView: TouchView, context: Context) {
|
||||
touchView.representer = self
|
||||
}
|
||||
|
||||
class TouchView: UIView, UIGestureRecognizerDelegate {
|
||||
var representer: TouchOverlay?
|
||||
private var startLocation: CGPoint?
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
@objc
|
||||
func longPress(gesture: UILongPressGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
startLocation = gesture.location(in: nil)
|
||||
task = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 200_000000)
|
||||
await MainActor.run { representer?.touchDown = true }
|
||||
} catch { }
|
||||
}
|
||||
case .ended:
|
||||
if hitTest(gesture.location(in: self), with: nil) == self {
|
||||
representer?.action()
|
||||
}
|
||||
task?.cancel()
|
||||
representer?.touchDown = false
|
||||
case .changed:
|
||||
if let startLocation {
|
||||
let location = gesture.location(in: nil)
|
||||
let dx = location.x - startLocation.x
|
||||
let dy = location.y - startLocation.y
|
||||
if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed }
|
||||
}
|
||||
case .cancelled, .failed:
|
||||
task?.cancel()
|
||||
representer?.touchDown = false
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith: UIGestureRecognizer
|
||||
) -> Bool { true }
|
||||
}
|
||||
}
|
||||
private func unreadCounter(_ unread: Int, color: Color) -> some View {
|
||||
unreadCountText(unread)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(color)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
|
||||
struct UserPicker_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var activeSheet: UserPickerSheet?
|
||||
|
||||
let m = ChatModel()
|
||||
m.users = [UserInfo.sampleData, UserInfo.sampleData]
|
||||
return UserPicker(
|
||||
userPickerShown: .constant(true),
|
||||
activeSheet: $activeSheet
|
||||
showSettings: Binding.constant(false),
|
||||
showConnectDesktop: Binding.constant(false),
|
||||
userPickerVisible: Binding.constant(true)
|
||||
)
|
||||
.environmentObject(m)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ enum DatabaseAlert: Identifiable {
|
||||
struct DatabaseView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let dismissSettingsSheet: DismissAction
|
||||
@Binding var showSettings: Bool
|
||||
@State private var runChat = false
|
||||
@State private var alert: DatabaseAlert? = nil
|
||||
@State private var showFileImporter = false
|
||||
@@ -270,12 +270,12 @@ struct DatabaseView: View {
|
||||
case let .archiveImportedWithErrors(errs):
|
||||
return Alert(
|
||||
title: Text("Chat database imported"),
|
||||
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
|
||||
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
|
||||
)
|
||||
case let .archiveExportedWithErrors(archivePath, errs):
|
||||
return Alert(
|
||||
title: Text("Chat database exported"),
|
||||
message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
showShareSheet(items: [archivePath])
|
||||
}
|
||||
@@ -439,7 +439,7 @@ struct DatabaseView: View {
|
||||
|
||||
private func startChat() {
|
||||
if m.chatDbChanged {
|
||||
dismissSettingsSheet()
|
||||
showSettings = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
resetChatCtrl()
|
||||
do {
|
||||
@@ -491,7 +491,7 @@ struct DatabaseView: View {
|
||||
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
|
||||
do {
|
||||
let chats = try apiGetChats()
|
||||
m.updateChats(chats)
|
||||
m.updateChats(with: chats)
|
||||
} catch let error {
|
||||
logger.error("apiGetChats: cannot update chats \(responseError(error))")
|
||||
}
|
||||
@@ -533,9 +533,7 @@ func deleteChatAsync() async throws {
|
||||
}
|
||||
|
||||
struct DatabaseView_Previews: PreviewProvider {
|
||||
@Environment(\.dismiss) static var mockDismiss
|
||||
|
||||
static var previews: some View {
|
||||
DatabaseView(dismissSettingsSheet: mockDismiss, chatItemTTL: .none)
|
||||
DatabaseView(showSettings: Binding.constant(false), chatItemTTL: .none)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,6 @@ import SwiftUI
|
||||
class AppSheetState: ObservableObject {
|
||||
static let shared = AppSheetState()
|
||||
@Published var scenePhaseActive: Bool = false
|
||||
|
||||
func redactionReasons(_ protectScreen: Bool) -> RedactionReasons {
|
||||
!protectScreen || scenePhaseActive
|
||||
? RedactionReasons()
|
||||
: RedactionReasons.placeholder
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrivacySensitive: ViewModifier {
|
||||
@@ -25,7 +19,11 @@ private struct PrivacySensitive: ViewModifier {
|
||||
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.redacted(reason: appSheetState.redactionReasons(protectScreen))
|
||||
if !protectScreen {
|
||||
content
|
||||
} else {
|
||||
content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,55 +14,50 @@ import SimpleXChat
|
||||
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
|
||||
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
|
||||
struct ChatItemClipped: ViewModifier {
|
||||
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
|
||||
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var tailEnabled = true
|
||||
private let chatItem: (content: CIContent, chatDir: CIDirection)?
|
||||
private let tailVisible: Bool
|
||||
|
||||
init() {
|
||||
self.chatItem = nil
|
||||
self.tailVisible = false
|
||||
}
|
||||
|
||||
init(_ ci: ChatItem, tailVisible: Bool) {
|
||||
self.chatItem = (ci.content, ci.chatDir)
|
||||
self.tailVisible = tailVisible
|
||||
}
|
||||
|
||||
private func shapeStyle() -> ChatItemShape.Style {
|
||||
if let ci = chatItem {
|
||||
switch ci.content {
|
||||
case
|
||||
.sndMsgContent,
|
||||
.rcvMsgContent,
|
||||
.rcvDecryptionError,
|
||||
.rcvIntegrityError,
|
||||
.invalidJSON:
|
||||
let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
|
||||
false
|
||||
} else {
|
||||
tailVisible
|
||||
}
|
||||
return tailEnabled
|
||||
? .bubble(
|
||||
padding: ci.chatDir.sent ? .trailing : .leading,
|
||||
tailVisible: tail
|
||||
)
|
||||
: .roundRect(radius: msgRectMaxRadius)
|
||||
case .rcvGroupInvitation, .sndGroupInvitation:
|
||||
return .roundRect(radius: msgRectMaxRadius)
|
||||
default: return .roundRect(radius: 8)
|
||||
}
|
||||
} else {
|
||||
return .roundRect(radius: msgRectMaxRadius)
|
||||
struct ClipShape: Shape {
|
||||
let maxCornerRadius: Double
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
Path(
|
||||
roundedRect: rect,
|
||||
cornerRadius: min((rect.height / 2), maxCornerRadius),
|
||||
style: .circular
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let clipShape = ChatItemShape(
|
||||
roundness: roundness,
|
||||
style: shapeStyle()
|
||||
|
||||
init() {
|
||||
clipShape = ClipShape(
|
||||
maxCornerRadius: 18
|
||||
)
|
||||
}
|
||||
|
||||
init(_ chatItem: ChatItem) {
|
||||
clipShape = ClipShape(
|
||||
maxCornerRadius: {
|
||||
switch chatItem.content {
|
||||
case
|
||||
.sndMsgContent,
|
||||
.rcvMsgContent,
|
||||
.rcvDecryptionError,
|
||||
.rcvGroupInvitation,
|
||||
.sndGroupInvitation,
|
||||
.sndDeleted,
|
||||
.rcvDeleted,
|
||||
.rcvIntegrityError,
|
||||
.sndModerated,
|
||||
.rcvModerated,
|
||||
.rcvBlocked,
|
||||
.invalidJSON: 18
|
||||
default: 8
|
||||
}
|
||||
}()
|
||||
)
|
||||
}
|
||||
|
||||
private let clipShape: ClipShape
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.contentShape(.dragPreview, clipShape)
|
||||
.contentShape(.contextMenuPreview, clipShape)
|
||||
@@ -70,106 +65,4 @@ struct ChatItemClipped: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatTailPadding: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content.padding(.horizontal, -msgTailWidth)
|
||||
}
|
||||
}
|
||||
|
||||
private let msgRectMaxRadius: Double = 18
|
||||
private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
|
||||
private let msgTailWidth: Double = 9
|
||||
private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
|
||||
private let msgTailMaxHeight: Double = msgTailWidth * 1.732 // 60deg
|
||||
|
||||
struct ChatItemShape: Shape {
|
||||
fileprivate enum Style {
|
||||
case bubble(padding: HorizontalEdge, tailVisible: Bool)
|
||||
case roundRect(radius: Double)
|
||||
}
|
||||
|
||||
fileprivate let roundness: Double
|
||||
fileprivate let style: Style
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
switch style {
|
||||
case let .bubble(padding, tailVisible):
|
||||
let w = rect.width
|
||||
let h = rect.height
|
||||
let rxMax = min(msgBubbleMaxRadius, w / 2)
|
||||
let ryMax = min(msgBubbleMaxRadius, h / 2)
|
||||
let rx = roundness * rxMax
|
||||
let ry = roundness * ryMax
|
||||
let tailHeight = min(msgTailMinHeight + roundness * (msgTailMaxHeight - msgTailMinHeight), h / 2)
|
||||
var path = Path()
|
||||
// top side
|
||||
path.move(to: CGPoint(x: rx, y: 0))
|
||||
path.addLine(to: CGPoint(x: w - rx, y: 0))
|
||||
if roundness > 0 {
|
||||
// top-right corner
|
||||
path.addQuadCurve(to: CGPoint(x: w, y: ry), control: CGPoint(x: w, y: 0))
|
||||
}
|
||||
if rect.height > 2 * ry {
|
||||
// right side
|
||||
path.addLine(to: CGPoint(x: w, y: h - ry))
|
||||
}
|
||||
if roundness > 0 {
|
||||
// bottom-right corner
|
||||
path.addQuadCurve(to: CGPoint(x: w - rx, y: h), control: CGPoint(x: w, y: h))
|
||||
}
|
||||
// bottom side
|
||||
if tailVisible {
|
||||
path.addLine(to: CGPoint(x: -msgTailWidth, y: h))
|
||||
if roundness > 0 {
|
||||
// bottom-left tail
|
||||
// distance of control point from touch point, calculated via ratios
|
||||
let d = tailHeight - msgTailWidth * msgTailWidth / tailHeight
|
||||
// tail control point
|
||||
let tc = CGPoint(x: 0, y: h - tailHeight + d * sqrt(roundness))
|
||||
// bottom-left tail curve
|
||||
path.addQuadCurve(to: CGPoint(x: 0, y: h - tailHeight), control: tc)
|
||||
} else {
|
||||
path.addLine(to: CGPoint(x: 0, y: h - tailHeight))
|
||||
}
|
||||
if rect.height > ry + tailHeight {
|
||||
// left side
|
||||
path.addLine(to: CGPoint(x: 0, y: ry))
|
||||
}
|
||||
} else {
|
||||
path.addLine(to: CGPoint(x: rx, y: h))
|
||||
path.addQuadCurve(to: CGPoint(x: 0, y: h - ry), control: CGPoint(x: 0 , y: h))
|
||||
if rect.height > 2 * ry {
|
||||
// left side
|
||||
path.addLine(to: CGPoint(x: 0, y: ry))
|
||||
}
|
||||
}
|
||||
if roundness > 0 {
|
||||
// top-left corner
|
||||
path.addQuadCurve(to: CGPoint(x: rx, y: 0), control: CGPoint(x: 0, y: 0))
|
||||
}
|
||||
path.closeSubpath()
|
||||
return switch padding {
|
||||
case .leading: path
|
||||
case .trailing: path
|
||||
.scale(x: -1, y: 1, anchor: .center)
|
||||
.path(in: rect)
|
||||
}
|
||||
case let .roundRect(radius):
|
||||
return Path(roundedRect: rect, cornerRadius: radius * roundness)
|
||||
}
|
||||
}
|
||||
|
||||
var offset: Double? {
|
||||
switch style {
|
||||
case let .bubble(padding, isTailVisible):
|
||||
if isTailVisible {
|
||||
switch padding {
|
||||
case .leading: -msgTailWidth
|
||||
case .trailing: msgTailWidth
|
||||
}
|
||||
} else { 0 }
|
||||
case .roundRect: 0
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ extension PresetWallpaper {
|
||||
scale
|
||||
} else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) {
|
||||
type.scale
|
||||
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename && $0.base == base })?.wallpaper?.scale {
|
||||
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename })?.wallpaper?.scale {
|
||||
scale
|
||||
} else {
|
||||
Float(1.0)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// Test.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Levitating Pineapple on 31/08/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func invertedForegroundStyle(enabled: Bool = true) -> some View {
|
||||
if enabled {
|
||||
foregroundStyle(Material.ultraThin)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.grayscale(1)
|
||||
.contrast(-20)
|
||||
} else { self }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// KeyboardPadding.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/07/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder func keyboardPadding() -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
GeometryReader { g in
|
||||
self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom))
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ struct ProfileImage: View {
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
||||
|
||||
var body: some View {
|
||||
if let uiImage = imageFromBase64(imageStr) {
|
||||
if let uiImage = UIImage(base64Encoded: imageStr) {
|
||||
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
|
||||
} else {
|
||||
let c = color.asAnotherColorFromSecondaryVariant(theme)
|
||||
|
||||
@@ -8,63 +8,15 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
func getTopViewController() -> UIViewController? {
|
||||
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
|
||||
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
|
||||
let rootViewController = keyWindow.rootViewController {
|
||||
// Find the top-most presented view controller
|
||||
var topController = rootViewController
|
||||
while let presentedViewController = topController.presentedViewController {
|
||||
topController = presentedViewController
|
||||
}
|
||||
return topController
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
|
||||
if let topController = getTopViewController() {
|
||||
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
|
||||
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
if let completed = completed {
|
||||
activityViewController.completionWithItemsHandler = { _, _, _, _ in
|
||||
completed()
|
||||
}
|
||||
}
|
||||
topController.present(activityViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func showAlert(
|
||||
title: String,
|
||||
message: String? = nil,
|
||||
buttonTitle: String,
|
||||
buttonAction: @escaping () -> Void,
|
||||
cancelButton: Bool
|
||||
) -> Void {
|
||||
if let topController = getTopViewController() {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
|
||||
buttonAction()
|
||||
})
|
||||
if cancelButton {
|
||||
alert.addAction(cancelAlertAction)
|
||||
let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() }
|
||||
activityViewController.completionWithItemsHandler = handler
|
||||
}
|
||||
topController.present(alert, animated: true)
|
||||
presentedViewController.present(activityViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func showAlert(
|
||||
_ title: String,
|
||||
message: String? = nil,
|
||||
actions: () -> [UIAlertAction] = { [okAlertAction] }
|
||||
) {
|
||||
if let topController = getTopViewController() {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
for action in actions() { alert.addAction(action) }
|
||||
topController.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
|
||||
|
||||
let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
//
|
||||
// SwiftUISheet.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by user on 23/09/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private let sheetAnimationDuration: Double = 0.35
|
||||
|
||||
// Refrence: https://easings.net/
|
||||
private let easeOutCubic = UICubicTimingParameters(
|
||||
controlPoint1: CGPoint(x: 0.215, y: 0.61),
|
||||
controlPoint2: CGPoint(x: 0.355, y: 1)
|
||||
)
|
||||
|
||||
struct Sheet<SheetContent: View>: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
@ViewBuilder let sheetContent: () -> SheetContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
content
|
||||
SheetRepresentable(isPresented: $isPresented, content: sheetContent())
|
||||
.allowsHitTesting(isPresented)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SheetRepresentable<Content: View>: UIViewControllerRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
let content: Content
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller<Content> {
|
||||
Controller(content: content, representer: self)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ sheetController: Controller<Content>, context: Context) {
|
||||
sheetController.animate(isPresented: isPresented)
|
||||
}
|
||||
|
||||
class Controller<C: View>: UIViewController {
|
||||
let hostingController: UIHostingController<C>
|
||||
private let animator = UIViewPropertyAnimator(
|
||||
duration: sheetAnimationDuration,
|
||||
timingParameters: easeOutCubic
|
||||
)
|
||||
private let representer: SheetRepresentable<C>
|
||||
private var retainedFraction: CGFloat = 0
|
||||
private var sheetHeight: Double { hostingController.view.frame.height }
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(content: C, representer: SheetRepresentable<C>) {
|
||||
self.representer = representer
|
||||
self.hostingController = UIHostingController(rootView: content)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) missing") }
|
||||
|
||||
deinit {
|
||||
animator.stopAnimation(true)
|
||||
animator.finishAnimation(at: .current)
|
||||
}
|
||||
|
||||
func animate(isPresented: Bool) {
|
||||
let alreadyAnimating = animator.isRunning && isPresented != animator.isReversed
|
||||
let sheetFullyDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0)
|
||||
let sheetFullyPresented = animator.fractionComplete == (animator.isReversed ? 0 : 1)
|
||||
|
||||
if !isPresented && sheetFullyDismissed ||
|
||||
isPresented && sheetFullyPresented ||
|
||||
alreadyAnimating {
|
||||
return
|
||||
}
|
||||
|
||||
animator.pauseAnimation()
|
||||
animator.isReversed = !isPresented
|
||||
animator.continueAnimation(
|
||||
withTimingParameters: isPresented
|
||||
? easeOutCubic
|
||||
: UICubicTimingParameters(animationCurve: .easeIn),
|
||||
durationFactor: 1 - animator.fractionComplete
|
||||
)
|
||||
handleVisibility()
|
||||
}
|
||||
|
||||
func handleVisibility() {
|
||||
if animator.isReversed {
|
||||
task = Task {
|
||||
do {
|
||||
let sleepDuration = UInt64(sheetAnimationDuration * Double(NSEC_PER_SEC))
|
||||
try await Task.sleep(nanoseconds: sleepDuration)
|
||||
view.isHidden = true
|
||||
} catch { }
|
||||
}
|
||||
} else {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
view.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
view.isHidden = true
|
||||
view.backgroundColor = .clear
|
||||
view.addGestureRecognizer(
|
||||
UITapGestureRecognizer(target: self, action: #selector(tap(gesture:)))
|
||||
)
|
||||
addChild(hostingController)
|
||||
hostingController.didMove(toParent: self)
|
||||
if let sheet = hostingController.view {
|
||||
sheet.isHidden = true
|
||||
sheet.clipsToBounds = true
|
||||
sheet.layer.cornerRadius = 10
|
||||
sheet.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
|
||||
sheet.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:))))
|
||||
sheet.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(sheet)
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
// Ensures animations are only setup once
|
||||
// on some iOS version `viewDidAppear` can get called on each state change.
|
||||
if hostingController.view.isHidden {
|
||||
hostingController.view.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight)
|
||||
hostingController.view.isHidden = false
|
||||
animator.pausesOnCompletion = true
|
||||
animator.addAnimations {
|
||||
self.hostingController.view.transform = .identity
|
||||
self.view.backgroundColor = UIColor {
|
||||
switch $0.userInterfaceStyle {
|
||||
case .dark: .black.withAlphaComponent(0.290)
|
||||
default: .black.withAlphaComponent(0.121)
|
||||
}
|
||||
}
|
||||
}
|
||||
animator.startAnimation()
|
||||
animator.pauseAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func pan(gesture: UIPanGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
animator.isReversed = false
|
||||
animator.pauseAnimation()
|
||||
retainedFraction = animator.fractionComplete
|
||||
case .changed:
|
||||
animator.fractionComplete = retainedFraction - gesture.translation(in: view).y / sheetHeight
|
||||
case .ended, .cancelled:
|
||||
let velocity = gesture.velocity(in: view).y
|
||||
animator.isReversed = (velocity - (animator.fractionComplete - 0.5) * 100).sign == .plus
|
||||
let defaultVelocity = sheetHeight / sheetAnimationDuration
|
||||
let fractionRemaining = 1 - animator.fractionComplete
|
||||
let durationFactor = min(max(fractionRemaining / (abs(velocity) / defaultVelocity), 0.5), 1)
|
||||
animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor * fractionRemaining)
|
||||
handleVisibility()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) {
|
||||
self.representer.isPresented = !self.animator.isReversed
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func tap(gesture: UITapGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .ended:
|
||||
if gesture.location(in: view).y < view.frame.height - sheetHeight {
|
||||
representer.isPresented = false
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
//
|
||||
// StickyScrollView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by user on 20/09/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct StickyScrollView<Content: View>: UIViewRepresentable {
|
||||
@Binding var resetScroll: ResetScrollAction
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
func makeUIView(context: Context) -> UIScrollView {
|
||||
let hc = context.coordinator.hostingController
|
||||
hc.view.backgroundColor = .clear
|
||||
let sv = UIScrollView()
|
||||
sv.showsHorizontalScrollIndicator = false
|
||||
sv.addSubview(hc.view)
|
||||
sv.delegate = context.coordinator
|
||||
DispatchQueue.main.async {
|
||||
resetScroll = ResetScrollAction { sv.setContentOffset(.zero, animated: false) }
|
||||
}
|
||||
return sv
|
||||
}
|
||||
|
||||
func updateUIView(_ scrollView: UIScrollView, context: Context) {
|
||||
let hc = context.coordinator.hostingController
|
||||
hc.rootView = content()
|
||||
hc.view.frame.size = hc.view.intrinsicContentSize
|
||||
scrollView.contentSize = hc.view.intrinsicContentSize
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(content: content())
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIScrollViewDelegate {
|
||||
let hostingController: UIHostingController<Content>
|
||||
|
||||
init(content: Content) {
|
||||
self.hostingController = UIHostingController(rootView: content)
|
||||
}
|
||||
|
||||
func scrollViewWillEndDragging(
|
||||
_ scrollView: UIScrollView,
|
||||
withVelocity velocity: CGPoint,
|
||||
targetContentOffset: UnsafeMutablePointer<CGPoint>
|
||||
) {
|
||||
if targetContentOffset.pointee.x < 32 {
|
||||
targetContentOffset.pointee.x = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ResetScrollAction {
|
||||
var action = { }
|
||||
func callAsFunction() { action() }
|
||||
}
|
||||
@@ -16,7 +16,6 @@ struct UserWallpaperEditor: View {
|
||||
@State var themeModeOverride: ThemeModeOverride
|
||||
@State var applyToMode: DefaultThemeMode?
|
||||
@State var showMore: Bool = false
|
||||
@State var showFileImporter: Bool = false
|
||||
@Binding var globalThemeUsed: Bool
|
||||
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
|
||||
|
||||
@@ -126,27 +125,24 @@ struct UserWallpaperEditor: View {
|
||||
|
||||
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
|
||||
|
||||
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes)
|
||||
ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
|
||||
}
|
||||
}
|
||||
.modifier(
|
||||
ThemeImporter(isPresented: $showFileImporter) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
|
||||
@@ -220,7 +216,6 @@ struct ChatWallpaperEditor: View {
|
||||
@State var themeModeOverride: ThemeModeOverride
|
||||
@State var applyToMode: DefaultThemeMode?
|
||||
@State var showMore: Bool = false
|
||||
@State var showFileImporter: Bool = false
|
||||
@Binding var globalThemeUsed: Bool
|
||||
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
|
||||
|
||||
@@ -333,27 +328,24 @@ struct ChatWallpaperEditor: View {
|
||||
|
||||
CustomizeThemeColorsSection(editColor: editColor)
|
||||
|
||||
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes)
|
||||
ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
|
||||
}
|
||||
}
|
||||
.modifier(
|
||||
ThemeImporter(isPresented: $showFileImporter) { imported in
|
||||
let importedFromString = imported.wallpaper?.importFromString()
|
||||
let importedType = importedFromString?.toAppWallpaper().type
|
||||
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
|
||||
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||
Task {
|
||||
await MainActor.run {
|
||||
themeModeOverride = res
|
||||
}
|
||||
await save(applyToMode, res)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// UserDefault.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by user on 14/10/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@propertyWrapper
|
||||
public struct UserDefault<Value: Equatable>: DynamicProperty {
|
||||
@StateObject private var observer = UserDefaultObserver()
|
||||
let initialValue: Value
|
||||
let key: String
|
||||
let store: UserDefaults
|
||||
|
||||
public init(
|
||||
wrappedValue: Value,
|
||||
_ key: String,
|
||||
store: UserDefaults = .standard
|
||||
) {
|
||||
self.initialValue = wrappedValue
|
||||
self.key = key
|
||||
self.store = store
|
||||
}
|
||||
|
||||
public var wrappedValue: Value {
|
||||
get {
|
||||
// Observer can only be accessed after the property wrapper is installed in view (runtime exception)
|
||||
observer.subscribe(to: key)
|
||||
return store.object(forKey: key) as? Value ?? initialValue
|
||||
}
|
||||
nonmutating set {
|
||||
store.set(newValue, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class UserDefaultObserver: ObservableObject {
|
||||
private var subscribed = false
|
||||
|
||||
func subscribe(to key: String) {
|
||||
if !subscribed {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(userDefaultsDidChange),
|
||||
name: UserDefaults.didChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
subscribed = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func userDefaultsDidChange(_ notification: Notification) {
|
||||
Task { @MainActor in objectWillChange.send() }
|
||||
}
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
}
|
||||
@@ -65,7 +65,8 @@ struct LocalAuthView: View {
|
||||
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
|
||||
m.chatId = nil
|
||||
ItemsModel.shared.reversedChatItems = []
|
||||
m.updateChats([])
|
||||
m.chats = []
|
||||
m.popChatCollector.clear()
|
||||
m.users = []
|
||||
_ = kcAppPassword.set(password)
|
||||
_ = kcSelfDestructPassword.remove()
|
||||
|
||||
@@ -24,7 +24,6 @@ private enum MigrationFromState: Equatable {
|
||||
}
|
||||
|
||||
private enum MigrateFromDeviceViewAlert: Identifiable {
|
||||
case finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl)
|
||||
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
|
||||
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
|
||||
|
||||
@@ -39,7 +38,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .finishMigration: return "finishMigration"
|
||||
case let .deleteChat(title, text): return "\(title) \(text)"
|
||||
case let .startChat(title, text): return "\(title) \(text)"
|
||||
|
||||
@@ -58,6 +56,8 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
|
||||
struct MigrateFromDevice: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var showProgressOnSettings: Bool
|
||||
@State private var migrationState: MigrationFromState = .chatStopInProgress
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@@ -106,6 +106,9 @@ struct MigrateFromDevice: View {
|
||||
finishedView(chatDeletion)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: "Back", disabled: $backDisabled) {
|
||||
dismiss()
|
||||
})
|
||||
.onChange(of: migrationState) { state in
|
||||
backDisabled = switch migrationState {
|
||||
case .chatStopInProgress, .archiving, .linkShown, .finished: true
|
||||
@@ -135,15 +138,6 @@ struct MigrateFromDevice: View {
|
||||
}
|
||||
.alert(item: $alert) { alert in
|
||||
switch alert {
|
||||
case let .finishMigration(fileId, ctrl):
|
||||
return Alert(
|
||||
title: Text("Remove archive?"),
|
||||
message: Text("The uploaded database archive will be permanently removed from the servers."),
|
||||
primaryButton: .destructive(Text("Continue")) {
|
||||
finishMigration(fileId, ctrl)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .startChat(title, text):
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
@@ -177,7 +171,7 @@ struct MigrateFromDevice: View {
|
||||
case let .archiveExportedWithErrors(archivePath, errs):
|
||||
return Alert(
|
||||
title: Text("Chat database exported"),
|
||||
message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
message: Text("You may migrate the exported database.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
Task { await uploadArchive(path: archivePath) }
|
||||
}
|
||||
@@ -324,7 +318,7 @@ struct MigrateFromDevice: View {
|
||||
Text("Cancel migration").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
Button(action: { alert = .finishMigration(fileId, ctrl) }) {
|
||||
Button(action: { finishMigration(fileId, ctrl) }) {
|
||||
settingsRow("checkmark", color: theme.colors.secondary) {
|
||||
Text("Finalize migration").foregroundColor(theme.colors.primary)
|
||||
}
|
||||
@@ -529,15 +523,9 @@ struct MigrateFromDevice: View {
|
||||
}
|
||||
case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
|
||||
let cfg = getNetCfg()
|
||||
let proxy: NetworkProxy? = if cfg.socksProxy == nil {
|
||||
nil
|
||||
} else {
|
||||
networkProxyDefault.get()
|
||||
}
|
||||
let data = MigrationFileLinkData.init(
|
||||
networkConfig: MigrationFileLinkData.NetworkConfig(
|
||||
socksProxy: cfg.socksProxy,
|
||||
networkProxy: proxy,
|
||||
hostMode: cfg.hostMode,
|
||||
requiredHostMode: cfg.requiredHostMode
|
||||
)
|
||||
@@ -602,7 +590,7 @@ struct MigrateFromDevice: View {
|
||||
} catch let error {
|
||||
fatalError("Error starting chat \(responseError(error))")
|
||||
}
|
||||
dismissAllSheets(animated: true)
|
||||
showSettings = false
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error deleting database", error: responseError(error))
|
||||
@@ -625,7 +613,9 @@ struct MigrateFromDevice: View {
|
||||
}
|
||||
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
|
||||
if dismiss || m.chatDbStatus != .ok {
|
||||
dismissAllSheets(animated: true)
|
||||
await MainActor.run {
|
||||
showSettings = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,6 +767,6 @@ private class MigrationChatReceiver {
|
||||
|
||||
struct MigrateFromDevice_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MigrateFromDevice(showProgressOnSettings: Binding.constant(false))
|
||||
MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ struct MigrateToDevice: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@Binding var migrationState: MigrationToState?
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var alert: MigrateToDeviceViewAlert?
|
||||
@@ -101,7 +102,6 @@ struct MigrateToDevice: View {
|
||||
// Prevent from hiding the view until migration is finished or app deleted
|
||||
@State private var backDisabled: Bool = false
|
||||
@State private var showQRCodeScanner: Bool = true
|
||||
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -197,8 +197,10 @@ struct MigrateToDevice: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
|
||||
pasteLinkView()
|
||||
if developerTools {
|
||||
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
|
||||
pasteLinkView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +218,7 @@ struct MigrateToDevice: View {
|
||||
} label: {
|
||||
Text("Tap to paste link")
|
||||
}
|
||||
.disabled(!pasteboardHasStrings)
|
||||
.disabled(!ChatModel.shared.pasteboardHasStrings)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
@@ -485,9 +487,6 @@ struct MigrateToDevice: View {
|
||||
do {
|
||||
if !hasChatCtrl() {
|
||||
chatInitControllerRemovingDatabases()
|
||||
} else if ChatModel.shared.chatRunning == true {
|
||||
// cannot delete storage if chat is running
|
||||
try await apiStopChat()
|
||||
}
|
||||
try await apiDeleteStorage()
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
@@ -557,22 +556,11 @@ struct MigrateToDevice: View {
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
|
||||
MigrationToDeviceState.save(nil)
|
||||
try ObjC.catchException {
|
||||
appSettings.importIntoApp()
|
||||
}
|
||||
do {
|
||||
try SimpleX.startChat(refreshInvitations: true)
|
||||
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
||||
}
|
||||
appSettings.importIntoApp()
|
||||
try SimpleX.startChat(refreshInvitations: true)
|
||||
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
|
||||
} catch let error {
|
||||
logger.error("Error importing settings: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error migrating settings"),
|
||||
message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error)))
|
||||
)
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
||||
}
|
||||
hideView()
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ struct AddContactLearnMore: View {
|
||||
Text("If you can't meet in person, show QR code in a video call, or share the link.")
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ struct AddGroupView: View {
|
||||
.navigationBarTitle("Group link")
|
||||
}
|
||||
} else {
|
||||
createGroupView()
|
||||
createGroupView().keyboardPadding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,13 +137,10 @@ struct AddGroupView: View {
|
||||
createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName)
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
Task {
|
||||
let resized: String? = if let image {
|
||||
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
await MainActor.run { profile.image = resized }
|
||||
if let image = image {
|
||||
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||
} else {
|
||||
profile.image = nil
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
|
||||
@@ -14,11 +14,9 @@ enum ContactType: Int {
|
||||
}
|
||||
|
||||
struct NewChatMenuButton: View {
|
||||
// do not use chatModel here because it prevents showing AddGroupMembersView after group creation and QR code after link creation on iOS 16
|
||||
// @EnvironmentObject var chatModel: ChatModel
|
||||
@State private var showNewChatSheet = false
|
||||
@State private var alert: SomeAlert? = nil
|
||||
@State private var pendingConnection: PendingContactConnection? = nil
|
||||
@State private var globalAlert: SomeAlert? = nil
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
@@ -30,14 +28,22 @@ struct NewChatMenuButton: View {
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.appSheet(isPresented: $showNewChatSheet) {
|
||||
NewChatSheet(pendingConnection: $pendingConnection)
|
||||
NewChatSheet(alert: $alert)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
.onDisappear {
|
||||
alert = cleanupPendingConnection(contactConnection: pendingConnection)
|
||||
pendingConnection = nil
|
||||
.alert(item: $alert) { a in
|
||||
return a.alert
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
// This is a workaround to show "Keep unused invitation" alert in both following cases:
|
||||
// - on going back from NewChatView to NewChatSheet,
|
||||
// - on dismissing NewChatMenuButton sheet while on NewChatView (skipping NewChatSheet)
|
||||
.onChange(of: alert?.id) { a in
|
||||
if !showNewChatSheet && alert != nil {
|
||||
globalAlert = alert
|
||||
alert = nil
|
||||
}
|
||||
}
|
||||
.alert(item: $globalAlert) { a in
|
||||
return a.alert
|
||||
}
|
||||
}
|
||||
@@ -54,8 +60,7 @@ struct NewChatSheet: View {
|
||||
@State private var searchText = ""
|
||||
@State private var searchShowingSimplexLink = false
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var alert: SomeAlert?
|
||||
@Binding var pendingConnection: PendingContactConnection?
|
||||
@Binding var alert: SomeAlert?
|
||||
|
||||
// Sheet height management
|
||||
@State private var isAddContactActive = false
|
||||
@@ -73,9 +78,6 @@ struct NewChatSheet: View {
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarHidden(searchMode)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.alert(item: $alert) { a in
|
||||
return a.alert
|
||||
}
|
||||
}
|
||||
if #available(iOS 16.0, *), oneHandUI {
|
||||
let sheetHeight: CGFloat = showArchive ? 575 : 500
|
||||
@@ -110,7 +112,7 @@ struct NewChatSheet: View {
|
||||
if (searchText.isEmpty) {
|
||||
Section {
|
||||
NavigationLink(isActive: $isAddContactActive) {
|
||||
NewChatView(selection: .invite, parentAlert: $alert, contactConnection: $pendingConnection)
|
||||
NewChatView(selection: .invite, parentAlert: $alert)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
@@ -120,7 +122,7 @@ struct NewChatSheet: View {
|
||||
}
|
||||
}
|
||||
NavigationLink(isActive: $isScanPasteLinkActive) {
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert, contactConnection: $pendingConnection)
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
@@ -197,7 +199,7 @@ func chatContactType(chat: Chat) -> ContactType {
|
||||
case .contactRequest:
|
||||
return .request
|
||||
case let .direct(contact):
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
return .card
|
||||
} else if contact.chatDeleted {
|
||||
return .chatDeleted
|
||||
|
||||
@@ -45,47 +45,18 @@ enum NewChatOption: Identifiable {
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
func cleanupPendingConnection(contactConnection: PendingContactConnection?) -> SomeAlert? {
|
||||
var alert: SomeAlert? = nil
|
||||
|
||||
if !(ChatModel.shared.showingInvitation?.connChatUsed ?? true),
|
||||
let conn = contactConnection {
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Keep unused invitation?"),
|
||||
message: Text("You can view invitation link again in connection details."),
|
||||
primaryButton: .default(Text("Keep")) {},
|
||||
secondaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await deleteChat(Chat(
|
||||
chatInfo: .contactConnection(contactConnection: conn),
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
}
|
||||
),
|
||||
id: "keepUnusedInvitation"
|
||||
)
|
||||
}
|
||||
|
||||
ChatModel.shared.showingInvitation = nil
|
||||
|
||||
return alert
|
||||
}
|
||||
|
||||
struct NewChatView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var selection: NewChatOption
|
||||
@State var showQRCodeScanner = false
|
||||
@State private var invitationUsed: Bool = false
|
||||
@State private var contactConnection: PendingContactConnection? = nil
|
||||
@State private var connReqInvitation: String = ""
|
||||
@State private var creatingConnReq = false
|
||||
@State var choosingProfile = false
|
||||
@State private var pastedLink: String = ""
|
||||
@State private var alert: NewChatViewAlert?
|
||||
@Binding var parentAlert: SomeAlert?
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -97,11 +68,6 @@ struct NewChatView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding()
|
||||
.onChange(of: $selection.wrappedValue) { opt in
|
||||
if opt == NewChatOption.connect {
|
||||
showQRCodeScanner = true
|
||||
}
|
||||
}
|
||||
|
||||
VStack {
|
||||
// it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions
|
||||
@@ -156,10 +122,26 @@ struct NewChatView: View {
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if !choosingProfile {
|
||||
parentAlert = cleanupPendingConnection(contactConnection: contactConnection)
|
||||
contactConnection = nil
|
||||
if !(m.showingInvitation?.connChatUsed ?? true),
|
||||
let conn = contactConnection {
|
||||
parentAlert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Keep unused invitation?"),
|
||||
message: Text("You can view invitation link again in connection details."),
|
||||
primaryButton: .default(Text("Keep")) {},
|
||||
secondaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await deleteChat(Chat(
|
||||
chatInfo: .contactConnection(contactConnection: conn),
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
}
|
||||
),
|
||||
id: "keepUnusedInvitation"
|
||||
)
|
||||
}
|
||||
m.showingInvitation = nil
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch(a) {
|
||||
@@ -177,8 +159,7 @@ struct NewChatView: View {
|
||||
InviteView(
|
||||
invitationUsed: $invitationUsed,
|
||||
contactConnection: $contactConnection,
|
||||
connReqInvitation: $connReqInvitation,
|
||||
choosingProfile: $choosingProfile
|
||||
connReqInvitation: connReqInvitation
|
||||
)
|
||||
} else if creatingConnReq {
|
||||
creatingLinkProgressView()
|
||||
@@ -229,22 +210,12 @@ struct NewChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func incognitoProfileImage() -> some View {
|
||||
Image(systemName: "theatermasks.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 30)
|
||||
.foregroundColor(.indigo)
|
||||
}
|
||||
|
||||
private struct InviteView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var invitationUsed: Bool
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
@Binding var connReqInvitation: String
|
||||
@Binding var choosingProfile: Bool
|
||||
|
||||
var connReqInvitation: String
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
|
||||
var body: some View {
|
||||
@@ -255,40 +226,28 @@ private struct InviteView: View {
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
|
||||
|
||||
qrCodeView()
|
||||
if let selectedProfile = chatModel.currentUser {
|
||||
Section {
|
||||
NavigationLink {
|
||||
ActiveProfilePicker(
|
||||
contactConnection: $contactConnection,
|
||||
connReqInvitation: $connReqInvitation,
|
||||
incognitoEnabled: $incognitoDefault,
|
||||
choosingProfile: $choosingProfile,
|
||||
selectedProfile: selectedProfile
|
||||
)
|
||||
} label: {
|
||||
HStack {
|
||||
if incognitoDefault {
|
||||
incognitoProfileImage()
|
||||
Text("Incognito")
|
||||
} else {
|
||||
ProfileImage(imageStr: chatModel.currentUser?.image, size: 30)
|
||||
Text(chatModel.currentUser?.chatViewName ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Share profile").foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if incognitoDefault {
|
||||
Text("A new random profile will be shared.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
} footer: {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
.onChange(of: incognitoDefault) { incognito in
|
||||
setInvitationUsed()
|
||||
}
|
||||
.onChange(of: chatModel.currentUser) { u in
|
||||
Task {
|
||||
do {
|
||||
if let contactConn = contactConnection,
|
||||
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
chatModel.updateContactConnection(conn)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
setInvitationUsed()
|
||||
}
|
||||
}
|
||||
@@ -311,7 +270,6 @@ private struct InviteView: View {
|
||||
private func qrCodeView() -> some View {
|
||||
Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) {
|
||||
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
|
||||
.id("simplex-qrcode-view-for-\(connReqInvitation)")
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
@@ -331,257 +289,6 @@ private struct InviteView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ProfileSwitchStatus {
|
||||
case switchingUser
|
||||
case switchingIncognito
|
||||
case idle
|
||||
}
|
||||
|
||||
private struct ActiveProfilePicker: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
@Binding var connReqInvitation: String
|
||||
@Binding var incognitoEnabled: Bool
|
||||
@Binding var choosingProfile: Bool
|
||||
@State private var alert: SomeAlert?
|
||||
@State private var profileSwitchStatus: ProfileSwitchStatus = .idle
|
||||
@State private var switchingProfileByTimeout = false
|
||||
@State private var lastSwitchingProfileByTimeoutCall: Double?
|
||||
@State private var profiles: [User] = []
|
||||
@State private var searchTextOrPassword = ""
|
||||
@State private var showIncognitoSheet = false
|
||||
@State private var incognitoFirst: Bool = false
|
||||
@State var selectedProfile: User
|
||||
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
|
||||
|
||||
var body: some View {
|
||||
viewBody()
|
||||
.navigationTitle("Select chat profile")
|
||||
.searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.autocorrectionDisabled(true)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
profiles = chatModel.users
|
||||
.map { $0.user }
|
||||
.sorted { u, _ in u.activeUser }
|
||||
}
|
||||
.onChange(of: incognitoEnabled) { incognito in
|
||||
if profileSwitchStatus != .switchingIncognito {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
if let contactConn = contactConnection,
|
||||
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
chatModel.updateContactConnection(conn)
|
||||
profileSwitchStatus = .idle
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
profileSwitchStatus = .idle
|
||||
incognitoEnabled = !incognito
|
||||
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
|
||||
let err = getErrorAlert(error, "Error changing to incognito!")
|
||||
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text(err.title),
|
||||
message: Text(err.message ?? "Error: \(responseError(error))")
|
||||
),
|
||||
id: "setConnectionIncognitoError"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: profileSwitchStatus) { sp in
|
||||
if sp != .idle {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
switchingProfileByTimeout = profileSwitchStatus != .idle
|
||||
}
|
||||
} else {
|
||||
switchingProfileByTimeout = false
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedProfile) { profile in
|
||||
if (profileSwitchStatus != .switchingUser) {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
if let contactConn = contactConnection,
|
||||
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
|
||||
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
connReqInvitation = conn.connReqInv ?? ""
|
||||
incognitoEnabled = false
|
||||
chatModel.updateContactConnection(conn)
|
||||
}
|
||||
do {
|
||||
try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil )
|
||||
await MainActor.run {
|
||||
profileSwitchStatus = .idle
|
||||
dismiss()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
profileSwitchStatus = .idle
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Error switching profile"),
|
||||
message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.")
|
||||
),
|
||||
id: "switchingProfileError"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
profileSwitchStatus = .idle
|
||||
if let currentUser = chatModel.currentUser {
|
||||
selectedProfile = currentUser
|
||||
}
|
||||
let err = getErrorAlert(error, "Error changing connection profile")
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text(err.title),
|
||||
message: Text(err.message ?? "Error: \(responseError(error))")
|
||||
),
|
||||
id: "changeConnectionUserError"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
a.alert
|
||||
}
|
||||
.onAppear {
|
||||
incognitoFirst = incognitoEnabled
|
||||
choosingProfile = true
|
||||
}
|
||||
.onDisappear {
|
||||
choosingProfile = false
|
||||
}
|
||||
.sheet(isPresented: $showIncognitoSheet) {
|
||||
IncognitoHelp()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
profilePicker()
|
||||
.allowsHitTesting(!switchingProfileByTimeout)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.overlay {
|
||||
if switchingProfileByTimeout {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func filteredProfiles() -> [User] {
|
||||
let s = trimmedSearchTextOrPassword
|
||||
let lower = s.localizedLowercase
|
||||
|
||||
return profiles.filter { u in
|
||||
if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
|
||||
return true
|
||||
}
|
||||
return correctPassword(u, s)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View {
|
||||
Button {
|
||||
if selectedProfile == user && incognitoEnabled {
|
||||
incognitoEnabled = false
|
||||
profileSwitchStatus = .switchingIncognito
|
||||
} else if selectedProfile != user {
|
||||
selectedProfile = user
|
||||
profileSwitchStatus = .switchingUser
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
ProfileImage(imageStr: user.image, size: 30)
|
||||
.padding(.trailing, 2)
|
||||
Text(user.chatViewName)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if selectedProfile == user, !incognitoEnabled {
|
||||
Image(systemName: "checkmark")
|
||||
.resizable().scaledToFit().frame(width: 16)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func profilePicker() -> some View {
|
||||
let incognitoOption = Button {
|
||||
if !incognitoEnabled {
|
||||
incognitoEnabled = true
|
||||
profileSwitchStatus = .switchingIncognito
|
||||
}
|
||||
} label : {
|
||||
HStack {
|
||||
incognitoProfileImage()
|
||||
Text("Incognito")
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.font(.system(size: 14))
|
||||
.onTapGesture {
|
||||
showIncognitoSheet = true
|
||||
}
|
||||
Spacer()
|
||||
if incognitoEnabled {
|
||||
Image(systemName: "checkmark")
|
||||
.resizable().scaledToFit().frame(width: 16)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List {
|
||||
let filteredProfiles = filteredProfiles()
|
||||
let activeProfile = filteredProfiles.first { u in u.activeUser }
|
||||
|
||||
if let selectedProfile = activeProfile {
|
||||
let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId }
|
||||
|
||||
if incognitoFirst {
|
||||
incognitoOption
|
||||
profilerPickerUserOption(selectedProfile)
|
||||
} else {
|
||||
profilerPickerUserOption(selectedProfile)
|
||||
incognitoOption
|
||||
}
|
||||
|
||||
ForEach(otherProfiles) { p in
|
||||
profilerPickerUserOption(p)
|
||||
}
|
||||
} else {
|
||||
incognitoOption
|
||||
ForEach(filteredProfiles) { p in
|
||||
profilerPickerUserOption(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(switchingProfileByTimeout ? 0.4 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -589,7 +296,6 @@ private struct ConnectView: View {
|
||||
@Binding var pastedLink: String
|
||||
@Binding var alert: NewChatViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -626,7 +332,7 @@ private struct ConnectView: View {
|
||||
} label: {
|
||||
Text("Tap to paste link")
|
||||
}
|
||||
.disabled(!pasteboardHasStrings)
|
||||
.disabled(!ChatModel.shared.pasteboardHasStrings)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
linkTextView(pastedLink)
|
||||
@@ -697,7 +403,6 @@ struct ScannerInView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundColor(Color.clear)
|
||||
switch cameraAuthorizationStatus {
|
||||
case .authorized, nil: EmptyView()
|
||||
case .restricted: Text("Camera not available")
|
||||
case .denied: Label("Enable camera access", systemImage: "camera")
|
||||
default: Label("Tap to scan", systemImage: "qrcode")
|
||||
@@ -717,26 +422,21 @@ struct ScannerInView: View {
|
||||
.disabled(cameraAuthorizationStatus == .restricted)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
.onAppear {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
cameraAuthorizationStatus = status
|
||||
if showQRCodeScanner {
|
||||
switch status {
|
||||
case .notDetermined: await askCameraAuthorizationAsync()
|
||||
case .notDetermined: askCameraAuthorization()
|
||||
case .restricted: showQRCodeScanner = false
|
||||
case .denied: showQRCodeScanner = false
|
||||
case .authorized: ()
|
||||
@unknown default: await askCameraAuthorizationAsync()
|
||||
@unknown default: askCameraAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func askCameraAuthorizationAsync() async {
|
||||
await AVCaptureDevice.requestAccess(for: .video)
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
}
|
||||
|
||||
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
|
||||
AVCaptureDevice.requestAccess(for: .video) { allowed in
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
@@ -1275,12 +975,10 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
struct NewChatView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var parentAlert: SomeAlert?
|
||||
@State var contactConnection: PendingContactConnection? = nil
|
||||
|
||||
NewChatView(
|
||||
selection: .invite,
|
||||
parentAlert: $parentAlert,
|
||||
contactConnection: $contactConnection
|
||||
parentAlert: $parentAlert
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,34 +49,34 @@ struct QRCode: View {
|
||||
ZStack {
|
||||
if let image = image {
|
||||
qrCodeImage(image)
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
if withLogo {
|
||||
let w = geo.size.width
|
||||
Image("icon-light")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w * 0.16, height: w * 0.16)
|
||||
.frame(width: w * 0.165, height: w * 0.165)
|
||||
.background(.white)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
if withLogo {
|
||||
let w = geo.size.width
|
||||
Image("icon-light")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w * 0.16, height: w * 0.16)
|
||||
.frame(width: w * 0.165, height: w * 0.165)
|
||||
.background(.white)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.onAppear {
|
||||
makeScreenshotFunc = {
|
||||
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
|
||||
onShare?()
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
} else {
|
||||
Color.clear.aspectRatio(1, contentMode: .fit)
|
||||
.onAppear {
|
||||
makeScreenshotFunc = {
|
||||
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
|
||||
onShare?()
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
.onTapGesture(perform: makeScreenshotFunc)
|
||||
.task { image = await generateImage(uri, tintColor: tintColor) }
|
||||
.onAppear {
|
||||
image = image ?? generateImage(uri, tintColor: tintColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ private func qrCodeImage(_ image: UIImage) -> some View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? {
|
||||
private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(uri.utf8)
|
||||
|
||||
@@ -77,6 +77,7 @@ struct CreateProfile: View {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ struct CreateFirstProfile: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
func onboardingButtons() -> some View {
|
||||
|
||||
@@ -467,39 +467,6 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
)
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.1",
|
||||
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "checkmark.shield",
|
||||
title: "Better security ✅",
|
||||
description: "SimpleX protocols reviewed by Trail of Bits."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "video",
|
||||
title: "Better calls",
|
||||
description: "Switch audio and video during the call."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "bolt",
|
||||
title: "Better notifications",
|
||||
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: nil,
|
||||
title: "Better user experience",
|
||||
description: nil,
|
||||
subfeatures: [
|
||||
("link", "Switch chat profile for 1-time invitations."),
|
||||
("message", "Customizable message shape."),
|
||||
("calendar", "Better message dates."),
|
||||
("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
|
||||
("flag", "Delete or moderate up to 200 messages.")
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
|
||||
@@ -14,6 +14,7 @@ struct ConnectDesktopView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var viaSettings = false
|
||||
@AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name
|
||||
@AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false
|
||||
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true
|
||||
@@ -56,6 +57,23 @@ struct ConnectDesktopView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viaSettings {
|
||||
viewBody
|
||||
.modifier(BackButton(label: "Back", disabled: Binding.constant(false)) {
|
||||
if m.activeRemoteCtrl {
|
||||
alert = .disconnectDesktop(action: .back)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
NavigationView {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var viewBody: some View {
|
||||
Group {
|
||||
let discovery = m.remoteCtrlSession?.discovery
|
||||
if discovery == true || (discovery == nil && !showConnectScreen) {
|
||||
|
||||
@@ -105,12 +105,7 @@ struct TerminalView: View {
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.toolbar {
|
||||
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Chat console").font(.headline)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chat console")
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
@@ -165,7 +160,7 @@ struct TerminalView_Previews: PreviewProvider {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.terminalItems = [
|
||||
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
|
||||
.resp(.now, ChatResponse.response(type: "newChatItems", json: "{}"))
|
||||
.resp(.now, ChatResponse.response(type: "newChatItem", json: "{}"))
|
||||
]
|
||||
return NavigationView {
|
||||
TerminalView()
|
||||
|
||||
+13
-102
@@ -36,10 +36,6 @@ struct AdvancedNetworkSettings: View {
|
||||
@State private var showSettingsAlert: NetworkSettingsAlert?
|
||||
@State private var onionHosts: OnionHosts = .no
|
||||
@State private var showSaveDialog = false
|
||||
@State private var netProxy = networkProxyDefault.get()
|
||||
@State private var currentNetProxy = networkProxyDefault.get()
|
||||
@State private var useNetProxy = false
|
||||
@State private var netProxyAuth = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -106,76 +102,6 @@ struct AdvancedNetworkSettings: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Use SOCKS proxy", isOn: $useNetProxy)
|
||||
Group {
|
||||
TextField("IP address", text: $netProxy.host)
|
||||
TextField(
|
||||
"Port",
|
||||
text: Binding(
|
||||
get: { netProxy.port > 0 ? "\(netProxy.port)" : "" },
|
||||
set: { s in
|
||||
netProxy.port = if let port = Int(s), port > 0 {
|
||||
port
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
Toggle("Proxy requires password", isOn: $netProxyAuth)
|
||||
if netProxyAuth {
|
||||
TextField("Username", text: $netProxy.username)
|
||||
PassphraseField(
|
||||
key: $netProxy.password,
|
||||
placeholder: "Password",
|
||||
valid: NetworkProxy.validCredential(netProxy.password)
|
||||
)
|
||||
}
|
||||
}
|
||||
.if(!useNetProxy) { $0.foregroundColor(theme.colors.secondary) }
|
||||
.disabled(!useNetProxy)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("SOCKS proxy").foregroundColor(theme.colors.secondary)
|
||||
if useNetProxy && !netProxy.valid {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle.fill").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if netProxyAuth {
|
||||
Text("Your credentials may be sent unencrypted.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
Text("Do not use credentials with proxy.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
.onChange(of: useNetProxy) { useNetProxy in
|
||||
netCfg.socksProxy = useNetProxy && currentNetProxy.valid
|
||||
? currentNetProxy.toProxyString()
|
||||
: nil
|
||||
netProxy = currentNetProxy
|
||||
netProxyAuth = netProxy.username != "" || netProxy.password != ""
|
||||
}
|
||||
.onChange(of: netProxyAuth) { netProxyAuth in
|
||||
if netProxyAuth {
|
||||
netProxy.auth = currentNetProxy.auth
|
||||
netProxy.username = currentNetProxy.username
|
||||
netProxy.password = currentNetProxy.password
|
||||
} else {
|
||||
netProxy.auth = .username
|
||||
netProxy.username = ""
|
||||
netProxy.password = ""
|
||||
}
|
||||
}
|
||||
.onChange(of: netProxy) { netProxy in
|
||||
netCfg.socksProxy = useNetProxy && netProxy.valid
|
||||
? netProxy.toProxyString()
|
||||
: nil
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||
@@ -196,14 +122,11 @@ struct AdvancedNetworkSettings: View {
|
||||
if developerTools {
|
||||
Section {
|
||||
Picker("Transport isolation", selection: $netCfg.sessionMode) {
|
||||
let modes = TransportSessionMode.values.contains(netCfg.sessionMode)
|
||||
? TransportSessionMode.values
|
||||
: TransportSessionMode.values + [netCfg.sessionMode]
|
||||
ForEach(modes, id: \.self) { Text($0.text) }
|
||||
ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
.frame(height: 36)
|
||||
} footer: {
|
||||
sessionModeInfo(netCfg.sessionMode)
|
||||
Text(sessionModeInfo(netCfg.sessionMode))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
@@ -233,19 +156,19 @@ struct AdvancedNetworkSettings: View {
|
||||
|
||||
Section {
|
||||
Button("Reset to defaults") {
|
||||
updateNetCfgView(NetCfg.defaults, NetworkProxy.def)
|
||||
updateNetCfgView(NetCfg.defaults)
|
||||
}
|
||||
.disabled(netCfg == NetCfg.defaults)
|
||||
|
||||
Button("Set timeouts for proxy/VPN") {
|
||||
updateNetCfgView(netCfg.withProxyTimeouts, netProxy)
|
||||
updateNetCfgView(netCfg.withProxyTimeouts)
|
||||
}
|
||||
.disabled(netCfg.hasProxyTimeouts)
|
||||
|
||||
Button("Save and reconnect") {
|
||||
showSettingsAlert = .update
|
||||
}
|
||||
.disabled(netCfg == currentNetCfg || (useNetProxy && !netProxy.valid))
|
||||
.disabled(netCfg == currentNetCfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,8 +182,7 @@ struct AdvancedNetworkSettings: View {
|
||||
if cfgLoaded { return }
|
||||
cfgLoaded = true
|
||||
currentNetCfg = getNetCfg()
|
||||
currentNetProxy = networkProxyDefault.get()
|
||||
updateNetCfgView(currentNetCfg, currentNetProxy)
|
||||
updateNetCfgView(currentNetCfg)
|
||||
}
|
||||
.alert(item: $showSettingsAlert) { a in
|
||||
switch a {
|
||||
@@ -284,7 +206,7 @@ struct AdvancedNetworkSettings: View {
|
||||
if netCfg == currentNetCfg {
|
||||
dismiss()
|
||||
cfgLoaded = false
|
||||
} else if !useNetProxy || netProxy.valid {
|
||||
} else {
|
||||
showSaveDialog = true
|
||||
}
|
||||
})
|
||||
@@ -299,26 +221,18 @@ struct AdvancedNetworkSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNetCfgView(_ cfg: NetCfg, _ proxy: NetworkProxy) {
|
||||
private func updateNetCfgView(_ cfg: NetCfg) {
|
||||
netCfg = cfg
|
||||
netProxy = proxy
|
||||
onionHosts = OnionHosts(netCfg: netCfg)
|
||||
enableKeepAlive = netCfg.enableKeepAlive
|
||||
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
|
||||
useNetProxy = netCfg.socksProxy != nil
|
||||
netProxyAuth = switch netProxy.auth {
|
||||
case .username: netProxy.username != "" || netProxy.password != ""
|
||||
case .isolate: false
|
||||
}
|
||||
}
|
||||
|
||||
private func saveNetCfg() -> Bool {
|
||||
do {
|
||||
try setNetworkConfig(netCfg)
|
||||
currentNetCfg = netCfg
|
||||
setNetCfg(netCfg, networkProxy: useNetProxy ? netProxy : nil)
|
||||
currentNetProxy = netProxy
|
||||
networkProxyDefault.set(netProxy)
|
||||
setNetCfg(netCfg)
|
||||
return true
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
@@ -356,13 +270,10 @@ struct AdvancedNetworkSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionModeInfo(_ mode: TransportSessionMode) -> Text {
|
||||
let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.")
|
||||
return switch mode {
|
||||
case .user: userMode
|
||||
case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.")
|
||||
case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.")
|
||||
case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.")
|
||||
private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey {
|
||||
switch mode {
|
||||
case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**."
|
||||
case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,15 +19,9 @@ extension AppSettings {
|
||||
val.hostMode = .publicHost
|
||||
val.requiredHostMode = true
|
||||
}
|
||||
if val.socksProxy != nil {
|
||||
val.socksProxy = networkProxy?.toProxyString()
|
||||
setNetCfg(val, networkProxy: networkProxy)
|
||||
} else {
|
||||
val.socksProxy = nil
|
||||
setNetCfg(val, networkProxy: nil)
|
||||
}
|
||||
val.socksProxy = nil
|
||||
setNetCfg(val)
|
||||
}
|
||||
if let val = networkProxy { networkProxyDefault.set(val) }
|
||||
if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) }
|
||||
if let val = privacyAskToApproveRelays { privacyAskToApproveRelaysGroupDefault.set(val) }
|
||||
if let val = privacyAcceptImages {
|
||||
@@ -58,12 +52,10 @@ extension AppSettings {
|
||||
profileImageCornerRadiusGroupDefault.set(val)
|
||||
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||
}
|
||||
if let val = uiChatItemRoundness { def.setValue(val, forKey: DEFAULT_CHAT_ITEM_ROUNDNESS)}
|
||||
if let val = uiChatItemTail { def.setValue(val, forKey: DEFAULT_CHAT_ITEM_TAIL)}
|
||||
if let val = uiColorScheme { currentThemeDefault.set(val) }
|
||||
if let val = uiDarkColorScheme { systemDarkThemeDefault.set(val) }
|
||||
if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) }
|
||||
if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) }
|
||||
if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
|
||||
if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
|
||||
if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
|
||||
if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
|
||||
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
|
||||
}
|
||||
|
||||
@@ -71,7 +63,6 @@ extension AppSettings {
|
||||
let def = UserDefaults.standard
|
||||
var c = AppSettings.defaults
|
||||
c.networkConfig = getNetCfg()
|
||||
c.networkProxy = networkProxyDefault.get()
|
||||
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
|
||||
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
|
||||
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
|
||||
@@ -93,8 +84,6 @@ extension AppSettings {
|
||||
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
|
||||
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
|
||||
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||
c.uiChatItemRoundness = def.double(forKey: DEFAULT_CHAT_ITEM_ROUNDNESS)
|
||||
c.uiChatItemTail = def.bool(forKey: DEFAULT_CHAT_ITEM_TAIL)
|
||||
c.uiColorScheme = currentThemeDefault.get()
|
||||
c.uiDarkColorScheme = systemDarkThemeDefault.get()
|
||||
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
|
||||
|
||||
@@ -33,8 +33,6 @@ struct AppearanceSettings: View {
|
||||
}()
|
||||
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
|
||||
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var chatItemRoundness = defaultChatItemRoundness
|
||||
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var chatItemTail = true
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
@@ -181,14 +179,6 @@ struct AppearanceSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Message shape").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Corner")
|
||||
Slider(value: $chatItemRoundness, in: 0...1, step: 0.05)
|
||||
}
|
||||
Toggle("Tail", isOn: $chatItemTail)
|
||||
}
|
||||
|
||||
Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) {
|
||||
HStack(spacing: 16) {
|
||||
if let img = m.currentUser?.image, img != "" {
|
||||
@@ -367,22 +357,21 @@ struct ChatThemePreview: View {
|
||||
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
|
||||
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
|
||||
HStack {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: alice)
|
||||
.modifier(ChatItemClipped(alice, tailVisible: true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
|
||||
.modifier(ChatItemClipped())
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: bob)
|
||||
.modifier(ChatItemClipped(bob, tailVisible: true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
|
||||
.modifier(ChatItemClipped())
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
if let wallpaperType, let wallpaperImage = wallpaperType.image, let backgroundColor, let tintColor {
|
||||
@@ -583,14 +572,11 @@ struct CustomizeThemeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: nil)
|
||||
}
|
||||
.modifier(
|
||||
ThemeImporter(isPresented: $showFileImporter) { theme in
|
||||
ImportExportThemeSection(perChat: nil, perUser: nil, save: { theme in
|
||||
ThemeManager.saveAndApplyThemeOverrides(theme)
|
||||
saveThemeToDatabase(nil)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
/// When changing app theme, user overrides are hidden. User overrides will be returned back after closing Appearance screen, see ThemeDestinationPicker()
|
||||
.interactiveDismissDisabled(true)
|
||||
}
|
||||
@@ -598,9 +584,10 @@ struct CustomizeThemeView: View {
|
||||
|
||||
struct ImportExportThemeSection: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var showFileImporter: Bool
|
||||
var perChat: ThemeModeOverride?
|
||||
var perUser: ThemeModeOverrides?
|
||||
var save: (ThemeOverrides) -> Void
|
||||
@State private var showFileImporter = false
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
@@ -628,47 +615,39 @@ struct ImportExportThemeSection: View {
|
||||
} label: {
|
||||
Text("Import theme").foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeImporter: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
var save: (ThemeOverrides) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.fileImporter(
|
||||
isPresented: $isPresented,
|
||||
allowedContentTypes: [.data/*.plainText*/],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(files) = result, let fileURL = files.first {
|
||||
do {
|
||||
var fileSize: Int? = nil
|
||||
if fileURL.startAccessingSecurityScopedResource() {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
fileSize = resourceValues.fileSize
|
||||
}
|
||||
if let fileSize = fileSize,
|
||||
// Same as Android/desktop
|
||||
fileSize <= 5_500_000 {
|
||||
if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
|
||||
save(theme)
|
||||
logger.error("Saved theme from file")
|
||||
} else {
|
||||
logger.error("Error decoding theme file")
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.data/*.plainText*/],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(files) = result, let fileURL = files.first {
|
||||
do {
|
||||
var fileSize: Int? = nil
|
||||
if fileURL.startAccessingSecurityScopedResource() {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
fileSize = resourceValues.fileSize
|
||||
}
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
} else {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
)
|
||||
if let fileSize = fileSize,
|
||||
// Same as Android/desktop
|
||||
fileSize <= 5_500_000 {
|
||||
if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
|
||||
save(theme)
|
||||
logger.error("Saved theme from file")
|
||||
} else {
|
||||
logger.error("Error decoding theme file")
|
||||
}
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
} else {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Appearance fileImporter error \(error.localizedDescription)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Appearance fileImporter error \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -818,7 +797,7 @@ struct ThemeDestinationPicker: View {
|
||||
@Binding var customizeThemeIsOpen: Bool
|
||||
|
||||
var body: some View {
|
||||
let values = [(nil, NSLocalizedString("All profiles", comment: "profile dropdown"))] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
|
||||
let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
|
||||
|
||||
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
|
||||
Picker("Apply to", selection: $themeUserDest) {
|
||||
|
||||
@@ -26,7 +26,6 @@ struct IncognitoHelp: View {
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
@@ -32,17 +32,6 @@ struct PreferencesView: View {
|
||||
.disabled(currentPreferences == preferences)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if currentPreferences != preferences {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Your chat preferences", comment: "alert title"),
|
||||
message: NSLocalizedString("Chat preferences were changed.", comment: "alert message"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: savePreferences,
|
||||
cancelButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
|
||||
|
||||
@@ -47,8 +47,6 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for
|
||||
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
|
||||
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
|
||||
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
|
||||
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
|
||||
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
|
||||
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
@@ -75,12 +73,8 @@ let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme"
|
||||
let DEFAULT_CURRENT_THEME_IDS = "currentThemeIds"
|
||||
let DEFAULT_THEME_OVERRIDES = "themeOverrides"
|
||||
|
||||
let DEFAULT_NETWORK_PROXY = "networkProxy"
|
||||
|
||||
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
|
||||
|
||||
let defaultChatItemRoundness: Double = 0.75
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_LA_NOTICE: false,
|
||||
DEFAULT_LA_NOTICE_SHOWN: false,
|
||||
@@ -104,8 +98,6 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_DEVELOPER_TOOLS: false,
|
||||
DEFAULT_ENCRYPTION_STARTED: false,
|
||||
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
|
||||
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
|
||||
DEFAULT_CHAT_ITEM_TAIL: true,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
|
||||
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
|
||||
@@ -253,14 +245,13 @@ public class CodableDefault<T: Codable> {
|
||||
}
|
||||
}
|
||||
|
||||
let networkProxyDefault: CodableDefault<NetworkProxy> = CodableDefault(defaults: UserDefaults.standard, forKey: DEFAULT_NETWORK_PROXY, withDefault: NetworkProxy.def)
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var showSettings: Bool
|
||||
@State private var showProgress: Bool = false
|
||||
|
||||
var body: some View {
|
||||
@@ -269,12 +260,71 @@ struct SettingsView: View {
|
||||
if showProgress {
|
||||
progressView()
|
||||
}
|
||||
if let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func settingsView() -> some View {
|
||||
let user = chatModel.currentUser
|
||||
NavigationView {
|
||||
List {
|
||||
Section(header: Text("You").foregroundColor(theme.colors.secondary)) {
|
||||
if let user = user {
|
||||
NavigationLink {
|
||||
UserProfile()
|
||||
.navigationTitle("Your current profile")
|
||||
.modifier(ThemedBackground())
|
||||
} label: {
|
||||
ProfilePreview(profileOf: user)
|
||||
.padding(.leading, -8)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
UserProfilesView(showSettings: $showSettings)
|
||||
} label: {
|
||||
settingsRow("person.crop.rectangle.stack", color: theme.colors.secondary) { Text("Your chat profiles") }
|
||||
}
|
||||
|
||||
|
||||
if let user = user {
|
||||
NavigationLink {
|
||||
UserAddressView(shareViaProfile: user.addressShared)
|
||||
.navigationTitle("SimpleX address")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("qrcode", color: theme.colors.secondary) { Text("Your SimpleX address") }
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
|
||||
.navigationTitle("Your preferences")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
settingsRow("switch.2", color: theme.colors.secondary) { Text("Chat preferences") }
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ConnectDesktopView(viaSettings: true)
|
||||
} label: {
|
||||
settingsRow("desktopcomputer", color: theme.colors.secondary) { Text("Use from desktop") }
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress)
|
||||
.navigationTitle("Migrate device")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
|
||||
}
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
|
||||
NavigationLink {
|
||||
NotificationsView()
|
||||
@@ -325,29 +375,14 @@ struct SettingsView: View {
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
|
||||
chatDatabaseRow()
|
||||
}
|
||||
|
||||
Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
|
||||
chatDatabaseRow()
|
||||
NavigationLink {
|
||||
MigrateFromDevice(showProgressOnSettings: $showProgress)
|
||||
.toolbar {
|
||||
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Migrate device").font(.headline)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
|
||||
if let user = user {
|
||||
NavigationLink {
|
||||
ChatHelp(dismissSettingsSheet: dismiss)
|
||||
ChatHelp(showSettings: $showSettings)
|
||||
.navigationTitle("Welcome \(user.displayName)!")
|
||||
.modifier(ThemedBackground())
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
@@ -372,7 +407,7 @@ struct SettingsView: View {
|
||||
}
|
||||
settingsRow("number", color: theme.colors.secondary) {
|
||||
Button("Send questions and ideas") {
|
||||
dismiss()
|
||||
showSettings = false
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
@@ -421,15 +456,16 @@ struct SettingsView: View {
|
||||
}
|
||||
.navigationTitle("Your settings")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.onDisappear {
|
||||
chatModel.showingTerminal = false
|
||||
chatModel.terminalItems = []
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
chatModel.showingTerminal = false
|
||||
chatModel.terminalItems = []
|
||||
}
|
||||
}
|
||||
|
||||
private func chatDatabaseRow() -> some View {
|
||||
NavigationLink {
|
||||
DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL)
|
||||
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
|
||||
.navigationTitle("Your chat database")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
@@ -507,25 +543,26 @@ struct ProfilePreview: View {
|
||||
HStack {
|
||||
ProfileImage(imageStr: profileOf.image, size: 44, color: color)
|
||||
.padding(.trailing, 6)
|
||||
profileName(profileOf).lineLimit(1)
|
||||
.padding(.vertical, 6)
|
||||
VStack(alignment: .leading) {
|
||||
Text(profileOf.displayName)
|
||||
.fontWeight(.bold)
|
||||
.font(.title2)
|
||||
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
|
||||
Text(profileOf.fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profileName(_ profileOf: NamedChat) -> Text {
|
||||
var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
|
||||
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
|
||||
t = t + Text(" (" + profileOf.fullName + ")")
|
||||
// .font(.callout)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.currentUser = User.sampleData
|
||||
return SettingsView()
|
||||
@State var showSettings = false
|
||||
|
||||
return SettingsView(showSettings: $showSettings)
|
||||
.environmentObject(chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct UserAddressView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var viaCreateLinkView = false
|
||||
@State var shareViaProfile = false
|
||||
@State private var aas = AutoAcceptState()
|
||||
@State private var savedAAS = AutoAcceptState()
|
||||
@@ -21,6 +22,7 @@ struct UserAddressView: View {
|
||||
@State private var showMailView = false
|
||||
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil
|
||||
@State private var alert: UserAddressAlert?
|
||||
@State private var showSaveDialogue = false
|
||||
@State private var progressIndicator = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
|
||||
@@ -42,19 +44,26 @@ struct UserAddressView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
userAddressScrollView()
|
||||
.onDisappear {
|
||||
if savedAAS != aas {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
|
||||
message: NSLocalizedString("Settings were changed.", comment: "alert message"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: saveAAS,
|
||||
cancelButton: true
|
||||
)
|
||||
if viaCreateLinkView {
|
||||
userAddressScrollView()
|
||||
} else {
|
||||
userAddressScrollView()
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if savedAAS == aas {
|
||||
dismiss()
|
||||
} else {
|
||||
keyboardVisible = false
|
||||
showSaveDialogue = true
|
||||
}
|
||||
})
|
||||
.confirmationDialog("Save settings?", isPresented: $showSaveDialogue) {
|
||||
Button("Save auto-accept settings") {
|
||||
saveAAS()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if progressIndicator {
|
||||
ZStack {
|
||||
if chatModel.userAddress != nil {
|
||||
@@ -333,7 +342,7 @@ struct UserAddressView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private struct AutoAcceptState: Equatable {
|
||||
var enable = false
|
||||
var incognito = false
|
||||
@@ -438,8 +447,6 @@ struct UserAddressView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
|
||||
|
||||
|
||||
return Group {
|
||||
UserAddressView()
|
||||
.environmentObject(chatModel)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ import SimpleXChat
|
||||
struct UserProfilesView: View {
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@EnvironmentObject private var theme: AppTheme
|
||||
@Binding var showSettings: Bool
|
||||
@Environment(\.editMode) private var editMode
|
||||
@AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true
|
||||
@AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true
|
||||
@@ -95,7 +96,8 @@ struct UserProfilesView: View {
|
||||
} label: {
|
||||
Label("Add profile", systemImage: "plus")
|
||||
}
|
||||
.frame(height: 38)
|
||||
.frame(height: 44)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
} footer: {
|
||||
Text("Tap to activate profile.")
|
||||
@@ -283,7 +285,7 @@ struct UserProfilesView: View {
|
||||
await MainActor.run {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
dismissAllSheets()
|
||||
showSettings = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -306,14 +308,14 @@ struct UserProfilesView: View {
|
||||
Task {
|
||||
do {
|
||||
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
|
||||
dismissAllSheets()
|
||||
} catch {
|
||||
await MainActor.run { alert = .activateUserError(error: responseError(error)) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
ProfileImage(imageStr: user.image, size: 38)
|
||||
ProfileImage(imageStr: user.image, size: 44)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.trailing, 12)
|
||||
Text(user.chatViewName)
|
||||
Spacer()
|
||||
@@ -404,15 +406,8 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
|
||||
return hash
|
||||
}
|
||||
|
||||
public func correctPassword(_ user: User, _ pwd: String) -> Bool {
|
||||
if let ph = user.viewPwdHash {
|
||||
return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
struct UserProfilesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UserProfilesView()
|
||||
UserProfilesView(showSettings: Binding.constant(true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,10 +137,6 @@
|
||||
<target>%@ иска да се свърже!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@, %@" xml:space="preserve">
|
||||
<source>%1$@, %2$@</source>
|
||||
<note>format for date separator in chat</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@, %@ and %lld members" xml:space="preserve">
|
||||
<source>%@, %@ and %lld members</source>
|
||||
<target>%@, %@ и %lld членове</target>
|
||||
@@ -161,31 +157,11 @@
|
||||
<target>%d дни</target>
|
||||
<note>time interval</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d file(s) are still being downloaded." xml:space="preserve">
|
||||
<source>%d file(s) are still being downloaded.</source>
|
||||
<note>forward confirmation reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d file(s) failed to download." xml:space="preserve">
|
||||
<source>%d file(s) failed to download.</source>
|
||||
<note>forward confirmation reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d file(s) were deleted." xml:space="preserve">
|
||||
<source>%d file(s) were deleted.</source>
|
||||
<note>forward confirmation reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d file(s) were not downloaded." xml:space="preserve">
|
||||
<source>%d file(s) were not downloaded.</source>
|
||||
<note>forward confirmation reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d hours" xml:space="preserve">
|
||||
<source>%d hours</source>
|
||||
<target>%d часа</target>
|
||||
<note>time interval</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d messages not forwarded" xml:space="preserve">
|
||||
<source>%d messages not forwarded</source>
|
||||
<note>alert title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d min" xml:space="preserve">
|
||||
<source>%d min</source>
|
||||
<target>%d мин.</target>
|
||||
@@ -737,7 +713,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="All profiles" xml:space="preserve">
|
||||
<source>All profiles</source>
|
||||
<note>profile dropdown</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All your contacts will remain connected." xml:space="preserve">
|
||||
<source>All your contacts will remain connected.</source>
|
||||
@@ -925,10 +901,6 @@
|
||||
<target>Кода за достъп до приложение се заменя с код за самоунищожение.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="App session" xml:space="preserve">
|
||||
<source>App session</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="App version" xml:space="preserve">
|
||||
<source>App version</source>
|
||||
<target>Версия на приложението</target>
|
||||
@@ -1031,10 +1003,6 @@
|
||||
<target>Автоматично приемане на изображения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Auto-accept settings" xml:space="preserve">
|
||||
<source>Auto-accept settings</source>
|
||||
<note>alert title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Back" xml:space="preserve">
|
||||
<source>Back</source>
|
||||
<target>Назад</target>
|
||||
@@ -1059,19 +1027,11 @@
|
||||
<target>Лош хеш на съобщението</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Better calls" xml:space="preserve">
|
||||
<source>Better calls</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Better groups" xml:space="preserve">
|
||||
<source>Better groups</source>
|
||||
<target>По-добри групи</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Better message dates." xml:space="preserve">
|
||||
<source>Better message dates.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Better messages" xml:space="preserve">
|
||||
<source>Better messages</source>
|
||||
<target>По-добри съобщения</target>
|
||||
@@ -1081,18 +1041,6 @@
|
||||
<source>Better networking</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Better notifications" xml:space="preserve">
|
||||
<source>Better notifications</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Better security ✅" xml:space="preserve">
|
||||
<source>Better security ✅</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Better user experience" xml:space="preserve">
|
||||
<source>Better user experience</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Black" xml:space="preserve">
|
||||
<source>Black</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -1219,7 +1167,7 @@
|
||||
<trans-unit id="Cancel" xml:space="preserve">
|
||||
<source>Cancel</source>
|
||||
<target>Отказ</target>
|
||||
<note>alert button</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Cancel migration" xml:space="preserve">
|
||||
<source>Cancel migration</source>
|
||||
@@ -1238,7 +1186,7 @@
|
||||
<trans-unit id="Cannot receive file" xml:space="preserve">
|
||||
<source>Cannot receive file</source>
|
||||
<target>Файлът не може да бъде получен</target>
|
||||
<note>alert title</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Capacity exceeded - recipient did not receive previously sent messages." xml:space="preserve">
|
||||
<source>Capacity exceeded - recipient did not receive previously sent messages.</source>
|
||||
@@ -1362,15 +1310,6 @@
|
||||
<target>Чат настройки</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat preferences were changed." xml:space="preserve">
|
||||
<source>Chat preferences were changed.</source>
|
||||
<note>alert message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat profile" xml:space="preserve">
|
||||
<source>Chat profile</source>
|
||||
<target>Потребителски профил</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat theme" xml:space="preserve">
|
||||
<source>Chat theme</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -1746,10 +1685,6 @@ This is your own one-time link!</source>
|
||||
<target>Версия на ядрото: v%@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Corner" xml:space="preserve">
|
||||
<source>Corner</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Correct name to %@?" xml:space="preserve">
|
||||
<source>Correct name to %@?</source>
|
||||
<target>Поправи име на %@?</target>
|
||||
@@ -1873,10 +1808,6 @@ This is your own one-time link!</source>
|
||||
<target>Персонализирано време</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Customizable message shape." xml:space="preserve">
|
||||
<source>Customizable message shape.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Customize theme" xml:space="preserve">
|
||||
<source>Customize theme</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -2166,10 +2097,6 @@ This is your own one-time link!</source>
|
||||
<target>Изтрий старата база данни?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Delete or moderate up to 200 messages." xml:space="preserve">
|
||||
<source>Delete or moderate up to 200 messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Delete pending connection?" xml:space="preserve">
|
||||
<source>Delete pending connection?</source>
|
||||
<target>Изтрий предстоящата връзка?</target>
|
||||
@@ -2407,10 +2334,6 @@ This is your own one-time link!</source>
|
||||
<target>Не изпращай история на нови членове.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Do not use credentials with proxy." xml:space="preserve">
|
||||
<source>Do not use credentials with proxy.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Don't create address" xml:space="preserve">
|
||||
<source>Don't create address</source>
|
||||
<target>Не създавай адрес</target>
|
||||
@@ -2434,8 +2357,7 @@ This is your own one-time link!</source>
|
||||
<trans-unit id="Download" xml:space="preserve">
|
||||
<source>Download</source>
|
||||
<target>Изтегли</target>
|
||||
<note>alert button
|
||||
chat item action</note>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Download errors" xml:space="preserve">
|
||||
<source>Download errors</source>
|
||||
@@ -2451,10 +2373,6 @@ This is your own one-time link!</source>
|
||||
<target>Свали файл</target>
|
||||
<note>server test step</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Download files" xml:space="preserve">
|
||||
<source>Download files</source>
|
||||
<note>alert action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Downloaded" xml:space="preserve">
|
||||
<source>Downloaded</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -2727,10 +2645,6 @@ This is your own one-time link!</source>
|
||||
<target>Грешка при промяна на адреса</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error changing connection profile" xml:space="preserve">
|
||||
<source>Error changing connection profile</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error changing role" xml:space="preserve">
|
||||
<source>Error changing role</source>
|
||||
<target>Грешка при промяна на ролята</target>
|
||||
@@ -2741,10 +2655,6 @@ This is your own one-time link!</source>
|
||||
<target>Грешка при промяна на настройката</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error changing to incognito!" xml:space="preserve">
|
||||
<source>Error changing to incognito!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
|
||||
<source>Error connecting to forwarding server %@. Please try later.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -2863,10 +2773,6 @@ This is your own one-time link!</source>
|
||||
<target>Грешка при зареждане на %@ сървъри</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error migrating settings" xml:space="preserve">
|
||||
<source>Error migrating settings</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error opening chat" xml:space="preserve">
|
||||
<source>Error opening chat</source>
|
||||
<target>Грешка при отваряне на чата</target>
|
||||
@@ -2875,7 +2781,7 @@ This is your own one-time link!</source>
|
||||
<trans-unit id="Error receiving file" xml:space="preserve">
|
||||
<source>Error receiving file</source>
|
||||
<target>Грешка при получаване на файл</target>
|
||||
<note>alert title</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error reconnecting server" xml:space="preserve">
|
||||
<source>Error reconnecting server</source>
|
||||
@@ -2964,14 +2870,10 @@ This is your own one-time link!</source>
|
||||
<target>Грешка при спиране на чата</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error switching profile" xml:space="preserve">
|
||||
<source>Error switching profile</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error switching profile!" xml:space="preserve">
|
||||
<source>Error switching profile!</source>
|
||||
<target>Грешка при смяна на профил!</target>
|
||||
<note>alertTitle</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error synchronizing connection" xml:space="preserve">
|
||||
<source>Error synchronizing connection</source>
|
||||
@@ -3016,7 +2918,8 @@ This is your own one-time link!</source>
|
||||
<trans-unit id="Error: %@" xml:space="preserve">
|
||||
<source>Error: %@</source>
|
||||
<target>Грешка: %@</target>
|
||||
<note>alert message</note>
|
||||
<note>file error text
|
||||
snd error text</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Error: URL is invalid" xml:space="preserve">
|
||||
<source>Error: URL is invalid</source>
|
||||
@@ -3100,11 +3003,6 @@ This is your own one-time link!</source>
|
||||
<source>File error</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="File errors: %@" xml:space="preserve">
|
||||
<source>File errors:
|
||||
%@</source>
|
||||
<note>alert message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="File not found - most likely file was deleted or cancelled." xml:space="preserve">
|
||||
<source>File not found - most likely file was deleted or cancelled.</source>
|
||||
<note>file error text</note>
|
||||
@@ -3235,27 +3133,11 @@ This is your own one-time link!</source>
|
||||
<target>Препрати</target>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forward %d message(s)?" xml:space="preserve">
|
||||
<source>Forward %d message(s)?</source>
|
||||
<note>alert title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forward and save messages" xml:space="preserve">
|
||||
<source>Forward and save messages</source>
|
||||
<target>Препращане и запазване на съобщения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forward messages" xml:space="preserve">
|
||||
<source>Forward messages</source>
|
||||
<note>alert action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forward messages without files?" xml:space="preserve">
|
||||
<source>Forward messages without files?</source>
|
||||
<note>alert message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forward up to 20 messages at once." xml:space="preserve">
|
||||
<source>Forward up to 20 messages at once.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forwarded" xml:space="preserve">
|
||||
<source>Forwarded</source>
|
||||
<target>Препратено</target>
|
||||
@@ -3266,10 +3148,6 @@ This is your own one-time link!</source>
|
||||
<target>Препратено от</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forwarding %lld messages" xml:space="preserve">
|
||||
<source>Forwarding %lld messages</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Forwarding server %@ failed to connect to destination server %@. Please try later." xml:space="preserve">
|
||||
<source>Forwarding server %@ failed to connect to destination server %@. Please try later.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -3312,6 +3190,11 @@ Error: %2$@</source>
|
||||
<target>Пълно име (незадължително)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Full name:" xml:space="preserve">
|
||||
<source>Full name:</source>
|
||||
<target>Пълно име:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Fully decentralized – visible only to members." xml:space="preserve">
|
||||
<source>Fully decentralized – visible only to members.</source>
|
||||
<target>Напълно децентрализирана – видима е само за членовете.</target>
|
||||
@@ -3555,10 +3438,6 @@ Error: %2$@</source>
|
||||
<target>ICE сървъри (по един на ред)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="IP address" xml:space="preserve">
|
||||
<source>IP address</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="If you can't meet in person, show QR code in a video call, or share the link." xml:space="preserve">
|
||||
<source>If you can't meet in person, show QR code in a video call, or share the link.</source>
|
||||
<target>Ако не можете да се срещнете лично, покажете QR код във видеоразговора или споделете линка.</target>
|
||||
@@ -3633,11 +3512,6 @@ Error: %2$@</source>
|
||||
<target>Импортиране на архив</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Improved delivery, reduced traffic usage. More improvements are coming soon!" xml:space="preserve">
|
||||
<source>Improved delivery, reduced traffic usage.
|
||||
More improvements are coming soon!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Improved message delivery" xml:space="preserve">
|
||||
<source>Improved message delivery</source>
|
||||
<target>Подобрена доставка на съобщения</target>
|
||||
@@ -4195,10 +4069,6 @@ This is your link for group %@!</source>
|
||||
<source>Message servers</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Message shape" xml:space="preserve">
|
||||
<source>Message shape</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Message source remains private." xml:space="preserve">
|
||||
<source>Message source remains private.</source>
|
||||
<target>Източникът на съобщението остава скрит.</target>
|
||||
@@ -4245,10 +4115,6 @@ This is your link for group %@!</source>
|
||||
<source>Messages sent</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Messages were deleted after you selected them." xml:space="preserve">
|
||||
<source>Messages were deleted after you selected them.</source>
|
||||
<note>alert message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." xml:space="preserve">
|
||||
<source>Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.</source>
|
||||
<target>Съобщенията, файловете и разговорите са защитени чрез **криптиране от край до край** с перфектна секретност при препращане, правдоподобно опровержение и възстановяване при взлом.</target>
|
||||
@@ -4403,14 +4269,6 @@ This is your link for group %@!</source>
|
||||
<target>Нов kод за достъп</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="New SOCKS credentials will be used every time you start the app." xml:space="preserve">
|
||||
<source>New SOCKS credentials will be used every time you start the app.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="New SOCKS credentials will be used for each server." xml:space="preserve">
|
||||
<source>New SOCKS credentials will be used for each server.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="New chat" xml:space="preserve">
|
||||
<source>New chat</source>
|
||||
<target>Нов чат</target>
|
||||
@@ -4527,14 +4385,6 @@ This is your link for group %@!</source>
|
||||
<target>Няма мрежова връзка</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="No permission to record speech" xml:space="preserve">
|
||||
<source>No permission to record speech</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="No permission to record video" xml:space="preserve">
|
||||
<source>No permission to record video</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="No permission to record voice message" xml:space="preserve">
|
||||
<source>No permission to record voice message</source>
|
||||
<target>Няма разрешение за запис на гласово съобщение</target>
|
||||
@@ -4554,10 +4404,6 @@ This is your link for group %@!</source>
|
||||
<source>Nothing selected</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Nothing to forward!" xml:space="preserve">
|
||||
<source>Nothing to forward!</source>
|
||||
<note>alert title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Notifications" xml:space="preserve">
|
||||
<source>Notifications</source>
|
||||
<target>Известия</target>
|
||||
@@ -4590,7 +4436,7 @@ This is your link for group %@!</source>
|
||||
<trans-unit id="Ok" xml:space="preserve">
|
||||
<source>Ok</source>
|
||||
<target>Ок</target>
|
||||
<note>alert button</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Old database" xml:space="preserve">
|
||||
<source>Old database</source>
|
||||
@@ -4778,11 +4624,6 @@ Requires compatible VPN.</source>
|
||||
<source>Other %@ servers</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Other file errors: %@" xml:space="preserve">
|
||||
<source>Other file errors:
|
||||
%@</source>
|
||||
<note>alert message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="PING count" xml:space="preserve">
|
||||
<source>PING count</source>
|
||||
<target>PING бройка</target>
|
||||
@@ -4818,10 +4659,6 @@ Requires compatible VPN.</source>
|
||||
<target>Кодът за достъп е зададен!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Password" xml:space="preserve">
|
||||
<source>Password</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Password to show" xml:space="preserve">
|
||||
<source>Password to show</source>
|
||||
<target>Парола за показване</target>
|
||||
@@ -4966,10 +4803,6 @@ Error: %@</source>
|
||||
<target>Полски интерфейс</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Port" xml:space="preserve">
|
||||
<source>Port</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Possibly, certificate fingerprint in server address is incorrect" xml:space="preserve">
|
||||
<source>Possibly, certificate fingerprint in server address is incorrect</source>
|
||||
<target>Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен</target>
|
||||
@@ -5050,6 +4883,16 @@ Error: %@</source>
|
||||
<target>Профилни изображения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Profile name" xml:space="preserve">
|
||||
<source>Profile name</source>
|
||||
<target>Име на профила</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Profile name:" xml:space="preserve">
|
||||
<source>Profile name:</source>
|
||||
<target>Име на профила:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Profile password" xml:space="preserve">
|
||||
<source>Profile password</source>
|
||||
<target>Профилна парола</target>
|
||||
@@ -5146,10 +4989,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<source>Proxied servers</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Proxy requires password" xml:space="preserve">
|
||||
<source>Proxy requires password</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Push notifications" xml:space="preserve">
|
||||
<source>Push notifications</source>
|
||||
<target>Push известия</target>
|
||||
@@ -5361,10 +5200,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Премахване</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Remove archive?" xml:space="preserve">
|
||||
<source>Remove archive?</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Remove image" xml:space="preserve">
|
||||
<source>Remove image</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -5538,10 +5373,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<source>SMP server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SOCKS proxy" xml:space="preserve">
|
||||
<source>SOCKS proxy</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Safely receive files" xml:space="preserve">
|
||||
<source>Safely receive files</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -5554,13 +5385,12 @@ Enable in *Network & servers* settings.</source>
|
||||
<trans-unit id="Save" xml:space="preserve">
|
||||
<source>Save</source>
|
||||
<target>Запази</target>
|
||||
<note>alert button
|
||||
chat item action</note>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
||||
<source>Save (and notify contacts)</source>
|
||||
<target>Запази (и уведоми контактите)</target>
|
||||
<note>alert button</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save and notify contact" xml:space="preserve">
|
||||
<source>Save and notify contact</source>
|
||||
@@ -5586,6 +5416,11 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Запази архив</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save auto-accept settings" xml:space="preserve">
|
||||
<source>Save auto-accept settings</source>
|
||||
<target>Запази настройките за автоматично приемане</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save group profile" xml:space="preserve">
|
||||
<source>Save group profile</source>
|
||||
<target>Запази профила на групата</target>
|
||||
@@ -5621,15 +5456,16 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Запази сървърите?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save settings?" xml:space="preserve">
|
||||
<source>Save settings?</source>
|
||||
<target>Запази настройките?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save welcome message?" xml:space="preserve">
|
||||
<source>Save welcome message?</source>
|
||||
<target>Запази съобщението при посрещане?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save your profile?" xml:space="preserve">
|
||||
<source>Save your profile?</source>
|
||||
<note>alert title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Saved" xml:space="preserve">
|
||||
<source>Saved</source>
|
||||
<target>Запазено</target>
|
||||
@@ -5650,10 +5486,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Запазено съобщение</target>
|
||||
<note>message info title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Saving %lld messages" xml:space="preserve">
|
||||
<source>Saving %lld messages</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Scale" xml:space="preserve">
|
||||
<source>Scale</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -5730,10 +5562,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Избери</target>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Select chat profile" xml:space="preserve">
|
||||
<source>Select chat profile</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Selected %lld" xml:space="preserve">
|
||||
<source>Selected %lld</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -5846,7 +5674,7 @@ Enable in *Network & servers* settings.</source>
|
||||
<trans-unit id="Sender cancelled file transfer." xml:space="preserve">
|
||||
<source>Sender cancelled file transfer.</source>
|
||||
<target>Подателят отмени прехвърлянето на файла.</target>
|
||||
<note>alert message</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Sender may have deleted the connection request." xml:space="preserve">
|
||||
<source>Sender may have deleted the connection request.</source>
|
||||
@@ -5938,10 +5766,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<source>Sent via proxy</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Server" xml:space="preserve">
|
||||
<source>Server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Server address" xml:space="preserve">
|
||||
<source>Server address</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -6053,10 +5877,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Настройки</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Settings were changed." xml:space="preserve">
|
||||
<source>Settings were changed.</source>
|
||||
<note>alert message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Shape profile images" xml:space="preserve">
|
||||
<source>Shape profile images</source>
|
||||
<target>Променете формата на профилните изображения</target>
|
||||
@@ -6091,10 +5911,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Сподели линк</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Share profile" xml:space="preserve">
|
||||
<source>Share profile</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Share this 1-time invite link" xml:space="preserve">
|
||||
<source>Share this 1-time invite link</source>
|
||||
<target>Сподели този еднократен линк за връзка</target>
|
||||
@@ -6225,10 +6041,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Еднократна покана за SimpleX</target>
|
||||
<note>simplex link type</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX protocols reviewed by Trail of Bits." xml:space="preserve">
|
||||
<source>SimpleX protocols reviewed by Trail of Bits.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Simplified incognito mode" xml:space="preserve">
|
||||
<source>Simplified incognito mode</source>
|
||||
<target>Опростен режим инкогнито</target>
|
||||
@@ -6257,10 +6069,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<source>Soft</source>
|
||||
<note>blur media</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Some app settings were not migrated." xml:space="preserve">
|
||||
<source>Some app settings were not migrated.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Some file(s) were not exported:" xml:space="preserve">
|
||||
<source>Some file(s) were not exported:</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -6393,14 +6201,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>Подкрепете SimpleX Chat</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Switch audio and video during the call." xml:space="preserve">
|
||||
<source>Switch audio and video during the call.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Switch chat profile for 1-time invitations." xml:space="preserve">
|
||||
<source>Switch chat profile for 1-time invitations.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="System" xml:space="preserve">
|
||||
<source>System</source>
|
||||
<target>Системен</target>
|
||||
@@ -6435,10 +6235,6 @@ Enable in *Network & servers* settings.</source>
|
||||
<target>TCP_KEEPINTVL</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Tail" xml:space="preserve">
|
||||
<source>Tail</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Take picture" xml:space="preserve">
|
||||
<source>Take picture</source>
|
||||
<target>Направи снимка</target>
|
||||
@@ -6627,10 +6423,6 @@ It can happen because of some bug or when the connection is compromised.</source
|
||||
<target>Текстът, който поставихте, не е SimpleX линк за връзка.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The uploaded database archive will be permanently removed from the servers." xml:space="preserve">
|
||||
<source>The uploaded database archive will be permanently removed from the servers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Themes" xml:space="preserve">
|
||||
<source>Themes</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -6754,14 +6546,6 @@ You will be prompted to complete authentication before this feature is enabled.<
|
||||
Ще бъдете подканени да извършите идентификация, преди тази функция да бъде активирана.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To record speech please grant permission to use Microphone." xml:space="preserve">
|
||||
<source>To record speech please grant permission to use Microphone.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To record video please grant permission to use Camera." xml:space="preserve">
|
||||
<source>To record video please grant permission to use Camera.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To record voice message please grant permission to use Microphone." xml:space="preserve">
|
||||
<source>To record voice message please grant permission to use Microphone.</source>
|
||||
<target>За да запишете гласово съобщение, моля, дайте разрешение за използване на микрофон.</target>
|
||||
@@ -6910,7 +6694,7 @@ You will be prompted to complete authentication before this feature is enabled.<
|
||||
</trans-unit>
|
||||
<trans-unit id="Unknown servers!" xml:space="preserve">
|
||||
<source>Unknown servers!</source>
|
||||
<note>alert title</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." xml:space="preserve">
|
||||
<source>Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions.</source>
|
||||
@@ -7020,10 +6804,6 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Използвай .onion хостове</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Use SOCKS proxy" xml:space="preserve">
|
||||
<source>Use SOCKS proxy</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Use SimpleX Chat servers?" xml:space="preserve">
|
||||
<source>Use SimpleX Chat servers?</source>
|
||||
<target>Използвай сървърите на SimpleX Chat?</target>
|
||||
@@ -7086,12 +6866,13 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>Use the app with one hand.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="User selection" xml:space="preserve">
|
||||
<source>User selection</source>
|
||||
<trans-unit id="User profile" xml:space="preserve">
|
||||
<source>User profile</source>
|
||||
<target>Потребителски профил</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Username" xml:space="preserve">
|
||||
<source>Username</source>
|
||||
<trans-unit id="User selection" xml:space="preserve">
|
||||
<source>User selection</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Using SimpleX Chat servers." xml:space="preserve">
|
||||
@@ -7323,7 +7104,7 @@ To connect, please ask your contact to create another connection link and check
|
||||
</trans-unit>
|
||||
<trans-unit id="Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." xml:space="preserve">
|
||||
<source>Without Tor or VPN, your IP address will be visible to these XFTP relays: %@.</source>
|
||||
<note>alert message</note>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Wrong database passphrase" xml:space="preserve">
|
||||
<source>Wrong database passphrase</source>
|
||||
@@ -7347,6 +7128,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>XFTP server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You" xml:space="preserve">
|
||||
<source>You</source>
|
||||
<target>Вие</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You **must not** use the same database on two devices." xml:space="preserve">
|
||||
<source>You **must not** use the same database on two devices.</source>
|
||||
<target>**Не трябва** да използвате една и съща база данни на две устройства.</target>
|
||||
@@ -7689,19 +7475,11 @@ Repeat connection request?</source>
|
||||
<target>Вашата чат база данни не е криптирана - задайте парола, за да я криптирате.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your chat preferences" xml:space="preserve">
|
||||
<source>Your chat preferences</source>
|
||||
<note>alert title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your chat profiles" xml:space="preserve">
|
||||
<source>Your chat profiles</source>
|
||||
<target>Вашите чат профили</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
|
||||
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
|
||||
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
|
||||
<target>Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%@).</target>
|
||||
@@ -7717,10 +7495,6 @@ Repeat connection request?</source>
|
||||
<target>Вашите контакти ще останат свързани.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your credentials may be sent unencrypted." xml:space="preserve">
|
||||
<source>Your credentials may be sent unencrypted.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your current chat database will be DELETED and REPLACED with the imported one." xml:space="preserve">
|
||||
<source>Your current chat database will be DELETED and REPLACED with the imported one.</source>
|
||||
<target>Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната.</target>
|
||||
@@ -7751,15 +7525,13 @@ Repeat connection request?</source>
|
||||
<target>Вашият профил **%@** ще бъде споделен.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." xml:space="preserve">
|
||||
<source>Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.</source>
|
||||
<target>Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил.</target>
|
||||
<trans-unit id="Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." xml:space="preserve">
|
||||
<source>Your profile is stored on your device and shared only with your contacts.
|
||||
SimpleX servers cannot see your profile.</source>
|
||||
<target>Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти.
|
||||
SimpleX сървърите не могат да видят вашия профил.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your profile was changed. If you save it, the updated profile will be sent to all your contacts." xml:space="preserve">
|
||||
<source>Your profile was changed. If you save it, the updated profile will be sent to all your contacts.</source>
|
||||
<note>alert message</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your profile, contacts and delivered messages are stored on your device." xml:space="preserve">
|
||||
<source>Your profile, contacts and delivered messages are stored on your device.</source>
|
||||
<target>Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство.</target>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user