Compare commits
394 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48119b07c3 | |||
| 23f54c1022 | |||
| 0b0b78293f | |||
| eef1e97ecc | |||
| 564b137f95 | |||
| 6edea46dad | |||
| 2fe3acf4df | |||
| 1c64b17545 | |||
| 700918f0ca | |||
| 94c552ca12 | |||
| dfe16991d0 | |||
| acb372a4ce | |||
| 121eaf6073 | |||
| 8cc075eda8 | |||
| 05e7f35037 | |||
| e582d2d742 | |||
| 043a4ed915 | |||
| 76cb9013f5 | |||
| f1e8c65aa1 | |||
| 8a6bf05773 | |||
| 0118e64ab4 | |||
| 0477b1aad3 | |||
| ae850c8ce8 | |||
| 94ae571ec3 | |||
| 4552860345 | |||
| a1579810bb | |||
| 4574198990 | |||
| 4d18174b11 | |||
| defd095a4f | |||
| ed60f28e56 | |||
| f0b889ffcf | |||
| c07df9e05f | |||
| efe8ed1739 | |||
| bcd50019be | |||
| 7b48c59f9f | |||
| 04033fc0b5 | |||
| 7b90e01b3a | |||
| ef1897f865 | |||
| f587179045 | |||
| 791489e943 | |||
| c485837910 | |||
| a95415fa1a | |||
| 8d48c4b14c | |||
| 9f44242e4c | |||
| 7e7d93c596 | |||
| 073818db55 | |||
| 519dd9e219 | |||
| 94218a1a7e | |||
| 996c6efddd | |||
| fd9c080103 | |||
| 5cb8badb22 | |||
| 0438f35539 | |||
| d5eb7b7811 | |||
| e04f74738e | |||
| 5f0ccb9f17 | |||
| b2d18f6960 | |||
| b52dfee078 | |||
| 70991debfd | |||
| 885aa9cfa5 | |||
| 75a468434c | |||
| 3b98032371 | |||
| 3a0921c093 | |||
| 3740805125 | |||
| b0e0b0beb8 | |||
| c823a4fa6c | |||
| 3c694e2841 | |||
| c159c2ede3 | |||
| 1d0d7bbd01 | |||
| 82c4d77c73 | |||
| 25938af62f | |||
| edfcced1fa | |||
| 5bff5a855e | |||
| 46a60d979b | |||
| f6ef57534f | |||
| 1e4479f736 | |||
| 789c762c81 | |||
| e9baeba31f | |||
| 2d5bbcdd61 | |||
| c3f67aff69 | |||
| 7cb3a499b2 | |||
| 32e7fd72d3 | |||
| 915bbed400 | |||
| 2ae5a8bffd | |||
| cd1550a14d | |||
| c72c461306 | |||
| 38fa4c231f | |||
| cb683d0706 | |||
| 84ae39b012 | |||
| 6a12f2dec8 | |||
| d1f704d160 | |||
| 9871ebb3b1 | |||
| 02c404593c | |||
| 1d9c5b7a0b | |||
| e20a4e54c3 | |||
| 1f8c69ec23 | |||
| 35c37263b8 | |||
| 9b70599cc5 | |||
| a4a11f99d2 | |||
| 7c8955fcdb | |||
| f922064f5c | |||
| 110839627f | |||
| c90b71120f | |||
| 3ec029e489 | |||
| f4c799aced | |||
| d970470702 | |||
| 9ee74bd36e | |||
| 0a4ff2e35f | |||
| a3550df893 | |||
| 3778698a6e | |||
| cf7a16e857 | |||
| bef1597fa1 | |||
| f06118835d | |||
| fa9e50a904 | |||
| 932a65630c | |||
| b649b64999 | |||
| e94ead3a4f | |||
| 42d7c20a47 | |||
| cf8fd2ea11 | |||
| 5006742577 | |||
| 38d46891a1 | |||
| 6a7e573b42 | |||
| b31a74567d | |||
| b3f0e71ded | |||
| ed50623ef5 | |||
| 53368713a0 | |||
| 5739b94808 | |||
| 0782408682 | |||
| 41576f80e7 | |||
| 2503a86f07 | |||
| dc713268e8 | |||
| d0c52e43a2 | |||
| 2e8e6cef7e | |||
| 239c815f3e | |||
| 1a6245fe51 | |||
| b3d15f97f0 | |||
| c0b8cfb3e2 | |||
| 818161c6ed | |||
| 4c53620dfa | |||
| ea5afb28d3 | |||
| 060760675d | |||
| 75101ac885 | |||
| 159f66e812 | |||
| 6d19b48979 | |||
| ce73a15787 | |||
| e6545a1747 | |||
| 601b081cab | |||
| d31dedf132 | |||
| 7441ed9892 | |||
| f6ee6338c4 | |||
| 5a42c0c1d2 | |||
| 240131a023 | |||
| 37e275c3ca | |||
| f893ad15de | |||
| a0763b3a43 | |||
| e6ba82c8ff | |||
| a8bdf1555f | |||
| d278b3dbcd | |||
| ac4b1e9406 | |||
| b04f159970 | |||
| 55331289d3 | |||
| b2b1519aea | |||
| e769abf14a | |||
| 9ca7031f82 | |||
| 7a418918d6 | |||
| 8f1302e1c6 | |||
| ee7c5d2aac | |||
| bf697c722a | |||
| f30dfa0be7 | |||
| 9c73ef9769 | |||
| e38db7fb44 | |||
| cb76c8079c | |||
| 8fbba16f53 | |||
| 5384e2826d | |||
| 0975079a93 | |||
| 74e2b7582e | |||
| 413a8f6b76 | |||
| 619cff91b2 | |||
| 2ee16f9398 | |||
| bfab2d9fb6 | |||
| 229ea80499 | |||
| 95de471754 | |||
| cbc86cd81e | |||
| 19cab39ee8 | |||
| 6fa3695ad6 | |||
| 6e6afdbd25 | |||
| 93e88c3953 | |||
| 676b533393 | |||
| e1fb0ac0b1 | |||
| 8bda64a5c1 | |||
| ab058d7222 | |||
| 4f9c53f561 | |||
| 5257c6f9ca | |||
| 6729b1fb4f | |||
| ac5351a752 | |||
| 2ff4619ca4 | |||
| 493ad14b39 | |||
| 7f08f87ee4 | |||
| ce1b66cef2 | |||
| 3e18fdc34d | |||
| c09075d71e | |||
| 41b4d7851a | |||
| 92bec5eabb | |||
| 24587ecc92 | |||
| 6865515f43 | |||
| 5ee6f40e75 | |||
| 637189cc2d | |||
| 13bfc9e92b | |||
| 032c5d3a5b | |||
| a53333be20 | |||
| a966f6b19d | |||
| 70d577260b | |||
| ea12982788 | |||
| 6fca6c22c5 | |||
| e343dd017b | |||
| 70a94d772a | |||
| 2689d1e27b | |||
| 4a9b54fbaf | |||
| f10a0ce58e | |||
| 6d488ba489 | |||
| 95776e0951 | |||
| bf9cdf3053 | |||
| fa73e63a79 | |||
| bfab76ed90 | |||
| 905295ee5f | |||
| a1e707ac1b | |||
| fea51b75e9 | |||
| ff8bbf11e7 | |||
| 391e9d57f2 | |||
| dae9f8575d | |||
| 065d9be614 | |||
| 4251762553 | |||
| c7017b3efc | |||
| b4ae3b9431 | |||
| 7138b09bae | |||
| bf59402856 | |||
| c6e5d6e2b8 | |||
| 0847b725b3 | |||
| 0d6f43a5ac | |||
| 5a014ba410 | |||
| 99888f9d5f | |||
| 670bf34ff5 | |||
| 3e873fcb32 | |||
| 23f24b1677 | |||
| 17526fa385 | |||
| 70204e071d | |||
| b348979b32 | |||
| 71ad8f2fd1 | |||
| 2dff94cbb4 | |||
| a73abfe642 | |||
| 859fa0bc22 | |||
| 41c4f13939 | |||
| f84ac713d7 | |||
| f41c04735b | |||
| a8da9b9cd9 | |||
| 64a0f509f7 | |||
| c4f8a50f0d | |||
| 93a4c0854e | |||
| 49c29c74df | |||
| e6ee5df158 | |||
| f4be0278b6 | |||
| a9d2535292 | |||
| 3e623684bc | |||
| fd90b47194 | |||
| acd3467d10 | |||
| 71ce598355 | |||
| 63393eaf0b | |||
| f90de83215 | |||
| 1c10209a31 | |||
| 5d7abf31ce | |||
| 44c0861fe4 | |||
| 1e6dc8002c | |||
| 0509ff9a1b | |||
| f12e504828 | |||
| 6a273677f1 | |||
| f5eea018d9 | |||
| 5f96f6c117 | |||
| e963ef53e7 | |||
| 1180edf80e | |||
| 4bd6517d19 | |||
| 2585f4ecfd | |||
| e22e01acd2 | |||
| 8e4299afb6 | |||
| ecff3c6ee5 | |||
| 85af368371 | |||
| 0d3928bd51 | |||
| ddeaa1c7c3 | |||
| 00ba468898 | |||
| 593c7d247c | |||
| 5907d8bd0c | |||
| 231082860e | |||
| c6366cb269 | |||
| d066c66282 | |||
| fab3827697 | |||
| d8126e0cd0 | |||
| 5baf8789d6 | |||
| dd5454f390 | |||
| fca098f535 | |||
| fba0478e50 | |||
| d951003191 | |||
| 1af513c548 | |||
| 85dc467f74 | |||
| 89e65c2fc1 | |||
| 0fa8d77214 | |||
| 6ba82c8c39 | |||
| e19c14b249 | |||
| d993381d31 | |||
| 0198c1f110 | |||
| 6c4c9b172c | |||
| d9a61af401 | |||
| a58522ef15 | |||
| bcd510c913 | |||
| c4083e00c5 | |||
| 7cd35b9068 | |||
| d3274b28ba | |||
| 40bd5dc2da | |||
| 1573b7af13 | |||
| a5bf3cbca8 | |||
| d5529031ee | |||
| d244338b13 | |||
| ce55c84b96 | |||
| c52f9baefa | |||
| ea1c5da65d | |||
| 5695bb9d46 | |||
| 13751d5705 | |||
| 02803b1491 | |||
| 222af69a71 | |||
| 5e96e1ea2b | |||
| 490e8cead8 | |||
| f578ee843b | |||
| cf2eb76cb7 | |||
| 48f9dcab9a | |||
| ac2ef54838 | |||
| 7e269c6b8c | |||
| 3e739924bb | |||
| 83706b19da | |||
| 6f19a7a20f | |||
| a8740cee4b | |||
| 4000fe383d | |||
| 75445e604e | |||
| 0596278e50 | |||
| 7a77775a42 | |||
| dc8d08ec04 | |||
| 4ad0e0f65e | |||
| d01515e755 | |||
| 3fa70ba217 | |||
| 6ae4cddc2e | |||
| ac8ee971f5 | |||
| 11d4872186 | |||
| 370e2e87ad | |||
| ef30fcf5e2 | |||
| 6ad1423f29 | |||
| c52e5349db | |||
| 3c98c6acc0 | |||
| c251f5e2bd | |||
| b165603136 | |||
| 184a24b70e | |||
| d46bae6498 | |||
| 5ac521f6d8 | |||
| 7b5b747b19 | |||
| 037655e30f | |||
| 54088c6d5a | |||
| 78f7fcc89f | |||
| b402caeb89 | |||
| ff8a8de3c8 | |||
| d22c0c2614 | |||
| 73c8d3c2e3 | |||
| 0110385e65 | |||
| ad0a678c5f | |||
| 0495a4cd78 | |||
| 02267c5263 | |||
| cad3bc048f | |||
| 1b04423745 | |||
| 2143eb2d7a | |||
| 3d395b0e45 | |||
| d79b4dfb5a | |||
| 0ae9f5ebba | |||
| 57c80786dc | |||
| da1cad96fc | |||
| b58ccedf23 | |||
| 01cadefde7 | |||
| c70e7223d9 | |||
| a1d4f52185 | |||
| 373070f388 | |||
| a03ccd0ba6 | |||
| a7982cccf9 | |||
| dccd6631af | |||
| b0516b6440 | |||
| bf91666f07 | |||
| ab47a5a27e | |||
| 23581f59ae | |||
| 291df6e9d0 | |||
| dae0b63c22 | |||
| 0964982782 | |||
| 9eb6034e1a |
@@ -5,12 +5,22 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- stable
|
- stable
|
||||||
- users
|
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "!*-fdroid"
|
- "!*-fdroid"
|
||||||
- "!*-armv7a"
|
- "!*-armv7a"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "apps/ios"
|
||||||
|
- "apps/multiplatform"
|
||||||
|
- "blog"
|
||||||
|
- "docs"
|
||||||
|
- "fastlane"
|
||||||
|
- "images"
|
||||||
|
- "packages"
|
||||||
|
- "website"
|
||||||
|
- "README.md"
|
||||||
|
- "PRIVACY.md"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-release:
|
prepare-release:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
||||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
|
||||||
|
|
||||||
[Learn more about SimpleX Chat](#contents).
|
[Learn more about SimpleX Chat](#contents).
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
|
|||||||
- contributing to SimpleX Chat knowledge-base.
|
- contributing to SimpleX Chat knowledge-base.
|
||||||
- developing features - please connect to us via chat so we can help you get started.
|
- developing features - please connect to us via chat so we can help you get started.
|
||||||
|
|
||||||
## Help us with donations
|
## Please support us with your donations
|
||||||
|
|
||||||
Huge thank you to everybody who donated to SimpleX Chat!
|
Huge thank you to everybody who donated to SimpleX Chat!
|
||||||
|
|
||||||
@@ -158,20 +158,19 @@ We are prioritizing users privacy and security - it would be impossible without
|
|||||||
|
|
||||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||||
|
|
||||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||||
|
|
||||||
It is possible to donate via:
|
It is possible to donate via:
|
||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
|
||||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
|
||||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
|
||||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- USDT:
|
- USDT:
|
||||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
|
||||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- please ask if you want to donate any other coins.
|
||||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
|
|
||||||
@@ -234,6 +233,10 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
Recent and important updates:
|
Recent and important updates:
|
||||||
|
|
||||||
|
[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)
|
[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 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)
|
||||||
@@ -382,10 +385,10 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
|||||||
- ✅ Private notes.
|
- ✅ Private notes.
|
||||||
- ✅ Improve sending videos (including encryption of locally stored videos).
|
- ✅ Improve sending videos (including encryption of locally stored videos).
|
||||||
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
||||||
|
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||||
- 🏗 Improve stability and reduce battery usage.
|
- 🏗 Improve stability and reduce battery usage.
|
||||||
- 🏗 Improve experience for the new users.
|
- 🏗 Improve experience for the new users.
|
||||||
- 🏗 Large groups, communities and public channels.
|
- 🏗 Large groups, communities and public channels.
|
||||||
- 🏗 Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
|
||||||
- Privacy & security slider - a simple way to set all settings at once.
|
- Privacy & security slider - a simple way to set all settings at once.
|
||||||
- SMP queue redundancy and rotation (manual is supported).
|
- SMP queue redundancy and rotation (manual is supported).
|
||||||
- Include optional message into connection request sent via contact address.
|
- Include optional message into connection request sent via contact address.
|
||||||
|
|||||||
@@ -9,35 +9,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||||
application.registerForRemoteNotifications()
|
application.registerForRemoteNotifications()
|
||||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||||
removePasscodesIfReinstalled()
|
removePasscodesIfReinstalled()
|
||||||
|
prepareForLaunch()
|
||||||
return true
|
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() {
|
@objc func pasteboardChanged() {
|
||||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||||
}
|
}
|
||||||
@@ -141,6 +124,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func prepareForLaunch() {
|
||||||
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
static func keepScreenOn(_ on: Bool) {
|
static func keepScreenOn(_ on: Bool) {
|
||||||
UIApplication.shared.isIdleTimerDisabled = on
|
UIApplication.shared.isIdleTimerDisabled = on
|
||||||
}
|
}
|
||||||
@@ -148,13 +135,79 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
|
|
||||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
static var windowStatic: UIWindow?
|
||||||
var windowScene: UIWindowScene?
|
var windowScene: UIWindowScene?
|
||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
UITableView.appearance().backgroundColor = .clear
|
||||||
guard let windowScene = scene as? UIWindowScene else { return }
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
self.windowScene = windowScene
|
self.windowScene = windowScene
|
||||||
window = windowScene.keyWindow
|
window = windowScene.keyWindow
|
||||||
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
|
SceneDelegate.windowStatic = windowScene.keyWindow
|
||||||
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
|
migrateAccentColorAndTheme()
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrateAccentColorAndTheme() {
|
||||||
|
let defs = UserDefaults.standard
|
||||||
|
/// For checking migration
|
||||||
|
// themeOverridesDefault.set([])
|
||||||
|
// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
|
||||||
|
let userInterfaceStyle = getUserInterfaceStyleDefault()
|
||||||
|
if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
|
||||||
|
// No migration needed or already migrated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
|
||||||
|
let accentColor = Color(cgColor: getUIAccentColorDefault())
|
||||||
|
if accentColor != defaultAccentColor {
|
||||||
|
let colors = ThemeColors(primary: accentColor.toReadableHex())
|
||||||
|
var overrides = themeOverridesDefault.get()
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
switch userInterfaceStyle {
|
||||||
|
case .light:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
|
||||||
|
case .dark:
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
|
||||||
|
case .unspecified:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
@unknown default: ()
|
||||||
|
}
|
||||||
|
} else if userInterfaceStyle != .unspecified {
|
||||||
|
let themeName = switch userInterfaceStyle {
|
||||||
|
case .light: DefaultTheme.LIGHT.themeName
|
||||||
|
case .dark: DefaultTheme.DARK.themeName
|
||||||
|
default: DefaultTheme.SYSTEM_THEME_NAME
|
||||||
|
}
|
||||||
|
ThemeManager.applyTheme(themeName)
|
||||||
|
}
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
@@ -1,153 +1,33 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "40.png",
|
"filename" : "any.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "universal",
|
||||||
"scale" : "2x",
|
"platform" : "ios",
|
||||||
"size" : "20x20"
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "60.png",
|
"appearances" : [
|
||||||
"idiom" : "iphone",
|
{
|
||||||
"scale" : "3x",
|
"appearance" : "luminosity",
|
||||||
"size" : "20x20"
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "dark.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "29.png",
|
"appearances" : [
|
||||||
"idiom" : "iphone",
|
{
|
||||||
"scale" : "1x",
|
"appearance" : "luminosity",
|
||||||
"size" : "29x29"
|
"value" : "tinted"
|
||||||
},
|
}
|
||||||
{
|
],
|
||||||
"filename" : "58.png",
|
"filename" : "tinted.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "universal",
|
||||||
"scale" : "2x",
|
"platform" : "ios",
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "87.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "80.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "120.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "57.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "57x57"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "114.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "57x57"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "120.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "180.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "20.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "29.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "58.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "80.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "50.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "50x50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "100.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "50x50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "72.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "72x72"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "144.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "72x72"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "76.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "152.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "167.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "83.5x83.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "1024.png",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,153 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "60.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "29.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "58.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "87.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "80.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "120.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "57.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "57x57"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "114.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "57x57"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "120.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "180.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "20.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "29.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "58.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "80.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "50.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "50x50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "100.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "50x50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "72.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "72x72"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "144.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "72x72"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "76.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "152.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "167.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "83.5x83.5"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"filename" : "1024.png",
|
"filename" : "1024.png",
|
||||||
"idiom" : "ios-marketing",
|
"idiom" : "universal",
|
||||||
"scale" : "1x",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -14,6 +14,8 @@ struct ContentView: View {
|
|||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@ObservedObject var callController = CallController.shared
|
@ObservedObject var callController = CallController.shared
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||||
|
|
||||||
var contentAccessAuthenticationExtended: Bool
|
var contentAccessAuthenticationExtended: Bool
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ struct ContentView: View {
|
|||||||
@State private var waitingForOrPassedAuth = true
|
@State private var waitingForOrPassedAuth = true
|
||||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||||
|
|
||||||
private let callTopPadding: CGFloat = 50
|
private let callTopPadding: CGFloat = 40
|
||||||
|
|
||||||
private enum ChatListActionSheet: Identifiable {
|
private enum ChatListActionSheet: Identifiable {
|
||||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||||
@@ -51,6 +53,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
allViews()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
} else {
|
||||||
|
// on iOS 15 scroll view background disabled in SceneDelegate
|
||||||
|
allViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func allViews() -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
||||||
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||||
@@ -138,6 +150,17 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
reactOnDarkThemeChanges(systemInDarkThemeCurrently)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.onChange(of: theme.name) { _ in
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contentView() -> some View {
|
@ViewBuilder private func contentView() -> some View {
|
||||||
@@ -184,7 +207,7 @@ struct ContentView: View {
|
|||||||
CallDuration(call: call)
|
CallDuration(call: call)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.frame(height: callTopPadding - 10)
|
.frame(height: callTopPadding)
|
||||||
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
chatModel.activeCallViewIsCollapsed = false
|
chatModel.activeCallViewIsCollapsed = false
|
||||||
@@ -224,7 +247,7 @@ struct ContentView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||||
.background(
|
.background(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.background)
|
.fill(theme.colors.background)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,103 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
|||||||
items.append(item)
|
items.append(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ItemsModel: ObservableObject {
|
||||||
|
static let shared = ItemsModel()
|
||||||
|
private let publisher = ObservableObjectPublisher()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
var reversedChatItems: [ChatItem] = [] {
|
||||||
|
willSet { publisher.send() }
|
||||||
|
}
|
||||||
|
var itemAdded = false {
|
||||||
|
willSet { publisher.send() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishes directly to `objectWillChange` publisher,
|
||||||
|
// this will cause reversedChatItems to be rendered without throttling
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var showLoadingProgress = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
publisher
|
||||||
|
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
|
||||||
|
.sink { self.objectWillChange.send() }
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
|
||||||
|
let navigationTimeout = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 250_000000)
|
||||||
|
await MainActor.run {
|
||||||
|
willNavigate()
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
let progressTimeout = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 1500_000000)
|
||||||
|
await MainActor.run { showLoadingProgress = true }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
if let chat = ChatModel.shared.getChat(chatId) {
|
||||||
|
await MainActor.run { self.isLoading = true }
|
||||||
|
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||||
|
await loadChat(chat: chat)
|
||||||
|
navigationTimeout.cancel()
|
||||||
|
progressTimeout.cancel()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isLoading = false
|
||||||
|
self.showLoadingProgress = false
|
||||||
|
willNavigate()
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkModel: ObservableObject {
|
||||||
|
// map of connections network statuses, key is agent connection id
|
||||||
|
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
||||||
|
|
||||||
|
static let shared = NetworkModel()
|
||||||
|
|
||||||
|
private init() { }
|
||||||
|
|
||||||
|
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
networkStatuses[conn.agentConnId] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
networkStatuses[conn.agentConnId] ?? .unknown
|
||||||
|
} else {
|
||||||
|
.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
final class ChatModel: ObservableObject {
|
||||||
@Published var onboardingStage: OnboardingStage?
|
@Published var onboardingStage: OnboardingStage?
|
||||||
@Published var setDeliveryReceipts = false
|
@Published var setDeliveryReceipts = false
|
||||||
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||||
@Published var currentUser: User?
|
@Published var currentUser: User? {
|
||||||
|
didSet {
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
@Published var users: [UserInfo] = []
|
@Published var users: [UserInfo] = []
|
||||||
@Published var chatInitialized = false
|
@Published var chatInitialized = false
|
||||||
@Published var chatRunning: Bool?
|
@Published var chatRunning: Bool?
|
||||||
@@ -59,16 +151,15 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var contentViewAccessAuthenticated: Bool = false
|
@Published var contentViewAccessAuthenticated: Bool = false
|
||||||
@Published var laRequest: LocalAuthRequest?
|
@Published var laRequest: LocalAuthRequest?
|
||||||
// list of chat "previews"
|
// list of chat "previews"
|
||||||
@Published var chats: [Chat] = []
|
@Published private(set) var chats: [Chat] = []
|
||||||
@Published var deletedChats: Set<String> = []
|
@Published var deletedChats: Set<String> = []
|
||||||
// map of connections network statuses, key is agent connection id
|
|
||||||
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
|
||||||
// current chat
|
// current chat
|
||||||
@Published var chatId: String?
|
@Published var chatId: String?
|
||||||
@Published var reversedChatItems: [ChatItem] = []
|
|
||||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||||
@Published var chatToTop: String?
|
@Published var chatToTop: String?
|
||||||
@Published var groupMembers: [GMember] = []
|
@Published var groupMembers: [GMember] = []
|
||||||
|
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
|
||||||
|
@Published var membersLoaded = false
|
||||||
// items in the terminal view
|
// items in the terminal view
|
||||||
@Published var showingTerminal = false
|
@Published var showingTerminal = false
|
||||||
@Published var terminalItems: [TerminalItem] = []
|
@Published var terminalItems: [TerminalItem] = []
|
||||||
@@ -100,8 +191,6 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||||
@Published var draft: ComposeState?
|
@Published var draft: ComposeState?
|
||||||
@Published var draftChatId: String?
|
@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 pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||||
|
|
||||||
@@ -111,6 +200,8 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
static let shared = ChatModel()
|
static let shared = ChatModel()
|
||||||
|
|
||||||
|
let im = ItemsModel.shared
|
||||||
|
|
||||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||||
|
|
||||||
let ntfEnableLocal = true
|
let ntfEnableLocal = true
|
||||||
@@ -176,18 +267,47 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func populateGroupMembersIndexes() {
|
||||||
|
groupMembersIndexes.removeAll()
|
||||||
|
for (i, member) in groupMembers.enumerated() {
|
||||||
|
groupMembersIndexes[member.groupMemberId] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
||||||
groupMembers.first { $0.groupMemberId == groupMemberId }
|
if let i = groupMembersIndexes[groupMemberId] {
|
||||||
|
return groupMembers[i]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
|
||||||
|
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||||
|
await MainActor.run {
|
||||||
|
if chatId == groupInfo.id {
|
||||||
|
self.groupMembers = groupMembers.map { GMember.init($0) }
|
||||||
|
self.populateGroupMembersIndexes()
|
||||||
|
self.membersLoaded = true
|
||||||
|
updateView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getChatIndex(_ id: String) -> Int? {
|
private func getChatIndex(_ id: String) -> Int? {
|
||||||
chats.firstIndex(where: { $0.id == id })
|
chats.firstIndex(where: { $0.id == id })
|
||||||
}
|
}
|
||||||
|
|
||||||
func addChat(_ chat: Chat, at position: Int = 0) {
|
func addChat(_ chat: Chat) {
|
||||||
withAnimation {
|
if chatId == nil {
|
||||||
chats.insert(chat, at: position)
|
withAnimation { addChat_(chat, at: 0) }
|
||||||
|
} else {
|
||||||
|
addChat_(chat, at: 0)
|
||||||
}
|
}
|
||||||
|
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addChat_(_ chat: Chat, at position: Int = 0) {
|
||||||
|
chats.insert(chat, at: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||||
@@ -245,26 +365,10 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateChats(with newChats: [ChatData]) {
|
func updateChats(_ newChats: [ChatData]) {
|
||||||
for i in 0..<newChats.count {
|
chats = newChats.map { Chat($0) }
|
||||||
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())
|
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
||||||
|
popChatCollector.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// func addGroup(_ group: SimpleXChat.Group) {
|
// func addGroup(_ group: SimpleXChat.Group) {
|
||||||
@@ -272,6 +376,12 @@ final class ChatModel: ObservableObject {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
|
// mark chat non deleted
|
||||||
|
if case let .direct(contact) = cInfo, contact.chatDeleted {
|
||||||
|
var updatedContact = contact
|
||||||
|
updatedContact.chatDeleted = false
|
||||||
|
updateContact(updatedContact)
|
||||||
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let i = getChatIndex(cInfo.id) {
|
if let i = getChatIndex(cInfo.id) {
|
||||||
chats[i].chatItems = switch cInfo {
|
chats[i].chatItems = switch cInfo {
|
||||||
@@ -289,18 +399,9 @@ final class ChatModel: ObservableObject {
|
|||||||
[cItem]
|
[cItem]
|
||||||
}
|
}
|
||||||
if case .rcvNew = cItem.meta.itemStatus {
|
if case .rcvNew = cItem.meta.itemStatus {
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
|
||||||
increaseUnreadCounter(user: currentUser!)
|
|
||||||
}
|
|
||||||
if i > 0 {
|
|
||||||
if chatId == nil {
|
|
||||||
withAnimation { popChat_(i) }
|
|
||||||
} else if chatId == cInfo.id {
|
|
||||||
chatToTop = cInfo.id
|
|
||||||
} else {
|
|
||||||
popChat_(i)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
|
||||||
} else {
|
} else {
|
||||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||||
}
|
}
|
||||||
@@ -315,7 +416,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var res: Bool
|
var res: Bool
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
if let pItem = chat.chatItems.last {
|
if let pItem = chat.chatItems.last {
|
||||||
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
||||||
chat.chatItems = [cItem]
|
chat.chatItems = [cItem]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -326,24 +427,26 @@ final class ChatModel: ObservableObject {
|
|||||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||||
res = true
|
res = true
|
||||||
}
|
}
|
||||||
|
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
|
||||||
|
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||||
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||||
if let i = getChatItemIndex(cItem) {
|
if let i = getChatItemIndex(cItem) {
|
||||||
withAnimation {
|
_updateChatItem(at: i, with: cItem)
|
||||||
_updateChatItem(at: i, with: cItem)
|
ChatItemDummyModel.shared.sendUpdate()
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation(itemAnimation()) {
|
var ci = cItem
|
||||||
var ci = cItem
|
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
ci.meta.itemStatus = status
|
||||||
ci.meta.itemStatus = status
|
|
||||||
}
|
|
||||||
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
|
||||||
}
|
}
|
||||||
|
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||||
|
im.itemAdded = true
|
||||||
|
ChatItemDummyModel.shared.sendUpdate()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +460,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||||
withAnimation {
|
withConditionalAnimation {
|
||||||
_updateChatItem(at: i, with: cItem)
|
_updateChatItem(at: i, with: cItem)
|
||||||
}
|
}
|
||||||
} else if let status = status {
|
} else if let status = status {
|
||||||
@@ -366,17 +469,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||||
reversedChatItems[i] = cItem
|
im.reversedChatItems[i] = cItem
|
||||||
reversedChatItems[i].viewTimestamp = .now
|
im.reversedChatItems[i].viewTimestamp = .now
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
if cItem.isRcvNew {
|
if cItem.isRcvNew {
|
||||||
decreaseUnreadCounter(cInfo)
|
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||||
}
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
@@ -388,23 +491,24 @@ final class ChatModel: ObservableObject {
|
|||||||
if chatId == cInfo.id {
|
if chatId == cInfo.id {
|
||||||
if let i = getChatItemIndex(cItem) {
|
if let i = getChatItemIndex(cItem) {
|
||||||
_ = withAnimation {
|
_ = withAnimation {
|
||||||
self.reversedChatItems.remove(at: i)
|
im.reversedChatItems.remove(at: i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
||||||
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
||||||
if previous {
|
if previous {
|
||||||
while i < reversedChatItems.count - 1 {
|
while i < im.reversedChatItems.count - 1 {
|
||||||
i += 1
|
i += 1
|
||||||
if let res = map(reversedChatItems[i]) { return res }
|
if let res = map(im.reversedChatItems[i]) { return res }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while i > 0 {
|
while i > 0 {
|
||||||
i -= 1
|
i -= 1
|
||||||
if let res = map(reversedChatItems[i]) { return res }
|
if let res = map(im.reversedChatItems[i]) { return res }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -422,10 +526,21 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
|
||||||
|
guard var current = currentUser else { return }
|
||||||
|
current.uiThemes = uiThemes
|
||||||
|
let i = users.firstIndex(where: { $0.user.userId == current.userId })
|
||||||
|
if let i {
|
||||||
|
users[i].user = current
|
||||||
|
}
|
||||||
|
currentUser = current
|
||||||
|
}
|
||||||
|
|
||||||
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
||||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||||
withAnimation {
|
withAnimation {
|
||||||
reversedChatItems.insert(cItem, at: 0)
|
im.reversedChatItems.insert(cItem, at: 0)
|
||||||
|
im.itemAdded = true
|
||||||
}
|
}
|
||||||
return cItem
|
return cItem
|
||||||
}
|
}
|
||||||
@@ -433,15 +548,15 @@ final class ChatModel: ObservableObject {
|
|||||||
func removeLiveDummy(animated: Bool = true) {
|
func removeLiveDummy(animated: Bool = true) {
|
||||||
if hasLiveDummy {
|
if hasLiveDummy {
|
||||||
if animated {
|
if animated {
|
||||||
withAnimation { _ = reversedChatItems.removeFirst() }
|
withAnimation { _ = im.reversedChatItems.removeFirst() }
|
||||||
} else {
|
} else {
|
||||||
_ = reversedChatItems.removeFirst()
|
_ = im.reversedChatItems.removeFirst()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hasLiveDummy: Bool {
|
private var hasLiveDummy: Bool {
|
||||||
reversedChatItems.first?.isLiveDummy == true
|
im.reversedChatItems.first?.isLiveDummy == true
|
||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||||
@@ -458,7 +573,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||||
var j = i
|
var j = i
|
||||||
while j < reversedChatItems.count {
|
while j < im.reversedChatItems.count {
|
||||||
markChatItemRead_(j)
|
markChatItemRead_(j)
|
||||||
j += 1
|
j += 1
|
||||||
}
|
}
|
||||||
@@ -472,7 +587,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var unreadBelow = 0
|
var unreadBelow = 0
|
||||||
var j = i - 1
|
var j = i - 1
|
||||||
while j >= 0 {
|
while j >= 0 {
|
||||||
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
|
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
|
||||||
unreadBelow += 1
|
unreadBelow += 1
|
||||||
}
|
}
|
||||||
j -= 1
|
j -= 1
|
||||||
@@ -507,51 +622,146 @@ final class ChatModel: ObservableObject {
|
|||||||
// clear current chat
|
// clear current chat
|
||||||
if chatId == cInfo.id {
|
if chatId == cInfo.id {
|
||||||
chatItemStatuses = [:]
|
chatItemStatuses = [:]
|
||||||
reversedChatItems = []
|
im.reversedChatItems = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||||
// update preview
|
if chatId == cInfo.id,
|
||||||
decreaseUnreadCounter(cInfo)
|
let itemIndex = getChatItemIndex(cItem),
|
||||||
// update current chat
|
im.reversedChatItems[itemIndex].isRcvNew {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
await MainActor.run {
|
||||||
markChatItemRead_(i)
|
withTransaction(Transaction()) {
|
||||||
}
|
// update current chat
|
||||||
}
|
markChatItemRead_(itemIndex)
|
||||||
|
// update preview
|
||||||
private func markChatItemRead_(_ i: Int) {
|
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||||
let meta = reversedChatItems[i].meta
|
}
|
||||||
if case .rcvNew = meta.itemStatus {
|
|
||||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
|
||||||
reversedChatItems[i].viewTimestamp = .now
|
|
||||||
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
|
||||||
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
private let unreadCollector = UnreadCollector()
|
||||||
if let i = getChatIndex(cInfo.id) {
|
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
class UnreadCollector {
|
||||||
decreaseUnreadCounter(user: currentUser!)
|
private let subject = PassthroughSubject<Void, Never>()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
private var unreadCounts: [ChatId: Int] = [:]
|
||||||
|
|
||||||
|
init() {
|
||||||
|
subject
|
||||||
|
.debounce(for: 1, scheduler: DispatchQueue.main)
|
||||||
|
.sink {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
for (chatId, count) in self.unreadCounts {
|
||||||
|
if let i = m.getChatIndex(chatId) {
|
||||||
|
m.changeUnreadCounter(i, by: count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.unreadCounts = [:]
|
||||||
|
}
|
||||||
|
.store(in: &bag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||||
|
}
|
||||||
|
subject.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let popChatCollector = PopChatCollector()
|
||||||
|
|
||||||
|
class PopChatCollector {
|
||||||
|
private let subject = PassthroughSubject<Void, Never>()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
private var chatsToPop: [ChatId: Date] = [:]
|
||||||
|
private let popTsComparator = KeyPathComparator<Chat>(\.popTs, order: .reverse)
|
||||||
|
|
||||||
|
init() {
|
||||||
|
subject
|
||||||
|
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
|
||||||
|
.sink { self.popCollectedChats() }
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
if currentPosition > 0 && m.chatId == chatId {
|
||||||
|
m.chatToTop = chatId
|
||||||
|
}
|
||||||
|
if currentPosition > 0 || !chatsToPop.isEmpty {
|
||||||
|
chatsToPop[chatId] = Date.now
|
||||||
|
subject.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
chatsToPop = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func popCollectedChats() {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
var ixs: IndexSet = []
|
||||||
|
var chs: [Chat] = []
|
||||||
|
// collect chats that received updates
|
||||||
|
for (chatId, popTs) in self.chatsToPop {
|
||||||
|
// Currently opened chat is excluded, removing it from the list would navigate out of it
|
||||||
|
// It will be popped to top later when user exits from the list.
|
||||||
|
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
|
||||||
|
ixs.insert(i)
|
||||||
|
let ch = m.chats[i]
|
||||||
|
ch.popTs = popTs
|
||||||
|
chs.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeInsert = {
|
||||||
|
m.chats.remove(atOffsets: ixs)
|
||||||
|
// sort chats by pop timestamp in descending order
|
||||||
|
m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.chatId == nil {
|
||||||
|
withAnimation { removeInsert() }
|
||||||
|
} else {
|
||||||
|
removeInsert()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.chatsToPop = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markChatItemRead_(_ i: Int) {
|
||||||
|
let meta = im.reversedChatItems[i].meta
|
||||||
|
if case .rcvNew = meta.itemStatus {
|
||||||
|
im.reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||||
|
im.reversedChatItems[i].viewTimestamp = .now
|
||||||
|
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
||||||
|
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
|
||||||
|
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
|
||||||
|
changeUnreadCounter(user: currentUser!, by: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func increaseUnreadCounter(user: any UserLike) {
|
func increaseUnreadCounter(user: any UserLike) {
|
||||||
changeUnreadCounter(user: user, by: 1)
|
changeUnreadCounter(user: user, by: 1)
|
||||||
NtfManager.shared.incNtfBadgeCount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||||
changeUnreadCounter(user: user, by: -by)
|
changeUnreadCounter(user: user, by: -by)
|
||||||
NtfManager.shared.decNtfBadgeCount(by: by)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
||||||
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
||||||
users[i].unreadCount += by
|
users[i].unreadCount += by
|
||||||
}
|
}
|
||||||
|
NtfManager.shared.changeNtfBadgeCount(by: by)
|
||||||
}
|
}
|
||||||
|
|
||||||
func totalUnreadCountForAllUsers() -> Int {
|
func totalUnreadCountForAllUsers() -> Int {
|
||||||
@@ -565,8 +775,8 @@ final class ChatModel: ObservableObject {
|
|||||||
var ns: [String] = []
|
var ns: [String] = []
|
||||||
if let ciCategory = chatItem.mergeCategory,
|
if let ciCategory = chatItem.mergeCategory,
|
||||||
var i = getChatItemIndex(chatItem) {
|
var i = getChatItemIndex(chatItem) {
|
||||||
while i < reversedChatItems.count {
|
while i < im.reversedChatItems.count {
|
||||||
let ci = reversedChatItems[i]
|
let ci = im.reversedChatItems[i]
|
||||||
if ci.mergeCategory != ciCategory { break }
|
if ci.mergeCategory != ciCategory { break }
|
||||||
if let m = ci.memberConnected {
|
if let m = ci.memberConnected {
|
||||||
ns.append(m.displayName)
|
ns.append(m.displayName)
|
||||||
@@ -581,7 +791,7 @@ final class ChatModel: ObservableObject {
|
|||||||
// returns the index of the passed item and the next item (it has smaller index)
|
// returns the index of the passed item and the next item (it has smaller index)
|
||||||
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
||||||
if let i = getChatItemIndex(ci) {
|
if let i = getChatItemIndex(ci) {
|
||||||
(i, i > 0 ? reversedChatItems[i - 1] : nil)
|
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
|
||||||
} else {
|
} else {
|
||||||
(nil, nil)
|
(nil, nil)
|
||||||
}
|
}
|
||||||
@@ -591,10 +801,10 @@ final class ChatModel: ObservableObject {
|
|||||||
// and the previous visible item with another merge category
|
// and the previous visible item with another merge category
|
||||||
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
||||||
guard var i = ciIndex else { return (nil, nil) }
|
guard var i = ciIndex else { return (nil, nil) }
|
||||||
let fst = reversedChatItems.count - 1
|
let fst = im.reversedChatItems.count - 1
|
||||||
while i < fst {
|
while i < fst {
|
||||||
i = i + 1
|
i = i + 1
|
||||||
let ci = reversedChatItems[i]
|
let ci = im.reversedChatItems[i]
|
||||||
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
||||||
return (i - 1, ci)
|
return (i - 1, ci)
|
||||||
}
|
}
|
||||||
@@ -607,7 +817,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var prevMember: GroupMember? = nil
|
var prevMember: GroupMember? = nil
|
||||||
var memberIds: Set<Int64> = []
|
var memberIds: Set<Int64> = []
|
||||||
for i in range {
|
for i in range {
|
||||||
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
|
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
|
||||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||||
memberIds.insert(m.groupMemberId)
|
memberIds.insert(m.groupMemberId)
|
||||||
}
|
}
|
||||||
@@ -617,6 +827,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func popChat(_ id: String) {
|
func popChat(_ id: String) {
|
||||||
if let i = getChatIndex(id) {
|
if let i = getChatIndex(id) {
|
||||||
|
// no animation here, for it not to look like it just moved when leaving the chat
|
||||||
popChat_(i)
|
popChat_(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,14 +862,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
if chatId == groupInfo.id {
|
if chatId == groupInfo.id {
|
||||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
if let i = groupMembersIndexes[member.groupMemberId] {
|
||||||
withAnimation(.default) {
|
withAnimation(.default) {
|
||||||
self.groupMembers[i].wrapped = member
|
self.groupMembers[i].wrapped = member
|
||||||
self.groupMembers[i].created = Date.now
|
self.groupMembers[i].created = Date.now
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation { groupMembers.append(GMember(member)) }
|
withAnimation {
|
||||||
|
groupMembers.append(GMember(member))
|
||||||
|
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -674,43 +888,6 @@ final class ChatModel: ObservableObject {
|
|||||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
|
||||||
var i = 0
|
|
||||||
var totalBelow = 0
|
|
||||||
var unreadBelow = 0
|
|
||||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
|
||||||
totalBelow += 1
|
|
||||||
if reversedChatItems[i].isRcvNew {
|
|
||||||
unreadBelow += 1
|
|
||||||
}
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
|
|
||||||
}
|
|
||||||
|
|
||||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
|
||||||
let maxIx = reversedChatItems.count - 1
|
|
||||||
var i = 0
|
|
||||||
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
|
|
||||||
while i < maxIx && !inView(i) { i += 1 }
|
|
||||||
while i < maxIx && inView(i) { i += 1 }
|
|
||||||
return reversedChatItems[min(i - 1, maxIx)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
|
||||||
if let conn = contact.activeConn {
|
|
||||||
networkStatuses[conn.agentConnId] = status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
|
||||||
if let conn = contact.activeConn {
|
|
||||||
networkStatuses[conn.agentConnId] ?? .unknown
|
|
||||||
} else {
|
|
||||||
.unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ShowingInvitation {
|
struct ShowingInvitation {
|
||||||
@@ -723,16 +900,12 @@ struct NTFContactRequest {
|
|||||||
var chatId: String
|
var chatId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnreadChatItemCounts {
|
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||||
var totalBelow: Int
|
|
||||||
var unreadBelow: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
final class Chat: ObservableObject, Identifiable {
|
|
||||||
@Published var chatInfo: ChatInfo
|
@Published var chatInfo: ChatInfo
|
||||||
@Published var chatItems: [ChatItem]
|
@Published var chatItems: [ChatItem]
|
||||||
@Published var chatStats: ChatStats
|
@Published var chatStats: ChatStats
|
||||||
var created = Date.now
|
var created = Date.now
|
||||||
|
fileprivate var popTs: Date?
|
||||||
|
|
||||||
init(_ cData: ChatData) {
|
init(_ cData: ChatData) {
|
||||||
self.chatInfo = cData.chatInfo
|
self.chatInfo = cData.chatInfo
|
||||||
@@ -779,24 +952,6 @@ final class Chat: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||||
|
|
||||||
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
|
|
||||||
if case let .group(groupInfo) = self.chatInfo {
|
|
||||||
let p = groupInfo.fullGroupPreferences
|
|
||||||
return switch feature {
|
|
||||||
case .timedMessages: p.timedMessages.on
|
|
||||||
case .directMessages: p.directMessages.on(for: groupInfo.membership)
|
|
||||||
case .fullDelete: p.fullDelete.on
|
|
||||||
case .reactions: p.reactions.on
|
|
||||||
case .voice: p.voice.on(for: groupInfo.membership)
|
|
||||||
case .files: p.files.on(for: groupInfo.membership)
|
|
||||||
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
|
|
||||||
case .history: p.history.on
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chatModel.chatId = content.targetContentIdentifier
|
if let chatId = content.targetContentIdentifier {
|
||||||
|
ItemsModel.shared.loadOpenChat(chatId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handler()
|
handler()
|
||||||
}
|
}
|
||||||
@@ -238,12 +240,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
ntfBadgeCountGroupDefault.set(count)
|
ntfBadgeCountGroupDefault.set(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decNtfBadgeCount(by count: Int = 1) {
|
func changeNtfBadgeCount(by count: Int = 1) {
|
||||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
|
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
|
||||||
}
|
|
||||||
|
|
||||||
func incNtfBadgeCount(by count: Int = 1) {
|
|
||||||
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addNotification(_ content: UNMutableNotificationContent) {
|
private func addNotification(_ content: UNMutableNotificationContent) {
|
||||||
|
|||||||
@@ -92,25 +92,29 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
|
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse {
|
||||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
if log {
|
||||||
|
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||||
|
}
|
||||||
let start = Date.now
|
let start = Date.now
|
||||||
let resp = bgTask
|
let resp = bgTask
|
||||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
||||||
: sendSimpleXCmd(cmd, ctrl)
|
: sendSimpleXCmd(cmd, ctrl)
|
||||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
if log {
|
||||||
if case let .response(_, json) = resp {
|
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
if case let .response(_, json) = resp {
|
||||||
}
|
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||||
Task {
|
}
|
||||||
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
Task {
|
||||||
|
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
|
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse {
|
||||||
await withCheckedContinuation { cont in
|
await withCheckedContinuation { cont in
|
||||||
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
|
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +137,8 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
|
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
|
||||||
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
|
let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
|
||||||
if case let .activeUser(user) = r { return user }
|
if case let .activeUser(user) = r { return user }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@@ -213,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||||
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
|
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
|
||||||
switch r {
|
switch r {
|
||||||
case .chatStarted: return true
|
case .chatStarted: return true
|
||||||
case .chatRunning: return false
|
case .chatRunning: return false
|
||||||
@@ -221,6 +225,15 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiCheckChatRunning() throws -> Bool {
|
||||||
|
let r = chatSendCmdSync(.checkChatRunning)
|
||||||
|
switch r {
|
||||||
|
case .chatRunning: return true
|
||||||
|
case .chatStopped: return false
|
||||||
|
default: throw r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func apiStopChat() async throws {
|
func apiStopChat() async throws {
|
||||||
let r = await chatSendCmd(.apiStopChat)
|
let r = await chatSendCmd(.apiStopChat)
|
||||||
switch r {
|
switch r {
|
||||||
@@ -242,14 +255,8 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
|
|||||||
logger.error("apiSuspendChat error: \(String(describing: r))")
|
logger.error("apiSuspendChat error: \(String(describing: r))")
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
|
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||||
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
|
let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl)
|
||||||
if case .cmdOk = r { return }
|
|
||||||
throw r
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
|
|
||||||
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
|
|
||||||
if case .cmdOk = r { return }
|
if case .cmdOk = r { return }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@@ -272,8 +279,10 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiExportArchive(config: ArchiveConfig) async throws {
|
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
let r = await chatSendCmd(.apiExportArchive(config: config))
|
||||||
|
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
|
||||||
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||||
@@ -309,8 +318,10 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
let loadItemsPerPage = 50
|
||||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
|
|
||||||
|
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||||
|
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@@ -321,15 +332,20 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadChat(chat: Chat, search: String = "") {
|
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||||
do {
|
do {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
|
let im = ItemsModel.shared
|
||||||
m.chatItemStatuses = [:]
|
m.chatItemStatuses = [:]
|
||||||
m.reversedChatItems = []
|
if clearItems {
|
||||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
await MainActor.run { im.reversedChatItems = [] }
|
||||||
m.updateChatInfo(chat.chatInfo)
|
}
|
||||||
m.reversedChatItems = chat.chatItems.reversed()
|
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||||
|
await MainActor.run {
|
||||||
|
im.reversedChatItems = chat.chatItems.reversed()
|
||||||
|
m.updateChatInfo(chat.chatInfo)
|
||||||
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("loadChat error: \(responseError(error))")
|
logger.error("loadChat error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -341,17 +357,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
|
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
|
||||||
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
|
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
|
||||||
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
|
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
|
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
|
||||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
|
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
|
||||||
return await processSendMessageCmd(toChatType: type, cmd: cmd)
|
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 chatModel = ChatModel.shared
|
||||||
let r: ChatResponse
|
let r: ChatResponse
|
||||||
if toChatType == .direct {
|
if toChatType == .direct {
|
||||||
@@ -364,10 +380,13 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
r = await chatSendCmd(cmd, bgTask: false)
|
r = await chatSendCmd(cmd, bgTask: false)
|
||||||
if case let .newChatItem(_, aChatItem) = r {
|
if case let .newChatItems(_, aChatItems) = r {
|
||||||
cItem = aChatItem.chatItem
|
let cItems = aChatItems.map { $0.chatItem }
|
||||||
chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
|
if let cItemLast = cItems.last {
|
||||||
return cItem
|
cItem = cItemLast
|
||||||
|
chatModel.messageDelivery[cItemLast.id] = endTask
|
||||||
|
}
|
||||||
|
return cItems
|
||||||
}
|
}
|
||||||
if let networkErrorAlert = networkErrorAlert(r) {
|
if let networkErrorAlert = networkErrorAlert(r) {
|
||||||
AlertManager.shared.showAlert(networkErrorAlert)
|
AlertManager.shared.showAlert(networkErrorAlert)
|
||||||
@@ -378,18 +397,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
|
|||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
r = await chatSendCmd(cmd, bgDelay: msgDelay)
|
r = await chatSendCmd(cmd, bgDelay: msgDelay)
|
||||||
if case let .newChatItem(_, aChatItem) = r {
|
if case let .newChatItems(_, aChatItems) = r {
|
||||||
return aChatItem.chatItem
|
return aChatItems.map { $0.chatItem }
|
||||||
}
|
}
|
||||||
sendMessageErrorAlert(r)
|
sendMessageErrorAlert(r)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
|
func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
|
||||||
let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
|
let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
|
||||||
if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
|
if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
|
||||||
createChatItemErrorAlert(r)
|
createChatItemsErrorAlert(r)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,15 +416,15 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
|||||||
logger.error("send message error: \(String(describing: r))")
|
logger.error("send message error: \(String(describing: r))")
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Error sending message",
|
title: "Error sending message",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createChatItemErrorAlert(_ r: ChatResponse) {
|
private func createChatItemsErrorAlert(_ r: ChatResponse) {
|
||||||
logger.error("apiCreateChatItem error: \(String(describing: r))")
|
logger.error("apiCreateChatItems error: \(String(describing: r))")
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Error creating message",
|
title: "Error creating message",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,15 +440,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
|
||||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
|
||||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
|
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
|
||||||
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
|
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
|
||||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +560,11 @@ func reconnectAllServers() async throws {
|
|||||||
try await sendCommandOkResp(.reconnectAllServers)
|
try await sendCommandOkResp(.reconnectAllServers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reconnectServer(smpServer: String) async throws {
|
||||||
|
let userId = try currentUserId("reconnectServer")
|
||||||
|
try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer))
|
||||||
|
}
|
||||||
|
|
||||||
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
||||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||||
}
|
}
|
||||||
@@ -561,6 +585,18 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Gro
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
|
||||||
|
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) {
|
||||||
|
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||||
|
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
|
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
|
||||||
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
|
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
|
||||||
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
|
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
|
||||||
@@ -640,6 +676,13 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
|
|||||||
throw r
|
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 {
|
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
|
||||||
let userId = try currentUserId("apiConnectPlan")
|
let userId = try currentUserId("apiConnectPlan")
|
||||||
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
|
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
|
||||||
@@ -672,7 +715,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
|||||||
return ((.contact, connection), nil)
|
return ((.contact, connection), nil)
|
||||||
case let .contactAlreadyExists(_, contact):
|
case let .contactAlreadyExists(_, contact):
|
||||||
if let c = m.getContactChat(contact.contactId) {
|
if let c = m.getContactChat(contact.contactId) {
|
||||||
await MainActor.run { m.chatId = c.id }
|
ItemsModel.shared.loadOpenChat(c.id)
|
||||||
}
|
}
|
||||||
let alert = contactAlreadyExistsAlert(contact)
|
let alert = contactAlreadyExistsAlert(contact)
|
||||||
return (nil, alert)
|
return (nil, alert)
|
||||||
@@ -682,7 +725,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
|||||||
message: "Please check that you used the correct link or ask your contact to send you another one."
|
message: "Please check that you used the correct link or ask your contact to send you another one."
|
||||||
)
|
)
|
||||||
return (nil, alert)
|
return (nil, alert)
|
||||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
|
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
|
||||||
let alert = mkAlert(
|
let alert = mkAlert(
|
||||||
title: "Connection error (AUTH)",
|
title: "Connection error (AUTH)",
|
||||||
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."
|
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."
|
||||||
@@ -715,7 +758,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
|||||||
} else {
|
} else {
|
||||||
return mkAlert(
|
return mkAlert(
|
||||||
title: "Connection error",
|
title: "Connection error",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -732,22 +775,38 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
|
|||||||
return (nil, alert)
|
return (nil, alert)
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
|
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
|
||||||
let chatId = type.rawValue + id.description
|
let chatId = type.rawValue + id.description
|
||||||
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
||||||
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
|
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
|
||||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
|
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
|
||||||
if case .direct = type, case .contactDeleted = r { return }
|
if case .direct = type, case .contactDeleted = r { return }
|
||||||
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
|
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
|
||||||
if case .group = type, case .groupDeletedUser = r { return }
|
if case .group = type, case .groupDeletedUser = r { return }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
|
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
|
||||||
|
let type: ChatType = .direct
|
||||||
|
let chatId = type.rawValue + id.description
|
||||||
|
if case .full = chatDeleteMode {
|
||||||
|
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
if case .full = chatDeleteMode {
|
||||||
|
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
|
||||||
|
if case let .contactDeleted(_, contact) = r { return contact }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
|
||||||
do {
|
do {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
|
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||||
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
|
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -757,6 +816,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
|
||||||
|
do {
|
||||||
|
let cInfo = chat.chatInfo
|
||||||
|
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||||
|
await MainActor.run {
|
||||||
|
switch chatDeleteMode {
|
||||||
|
case .full:
|
||||||
|
ChatModel.shared.removeChat(cInfo.id)
|
||||||
|
case .entity:
|
||||||
|
ChatModel.shared.removeChat(cInfo.id)
|
||||||
|
ChatModel.shared.addChat(Chat(
|
||||||
|
chatInfo: .direct(contact: ct),
|
||||||
|
chatItems: chat.chatItems
|
||||||
|
))
|
||||||
|
case .messages:
|
||||||
|
ChatModel.shared.removeChat(cInfo.id)
|
||||||
|
ChatModel.shared.addChat(Chat(
|
||||||
|
chatInfo: .direct(contact: ct),
|
||||||
|
chatItems: []
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
|
||||||
|
return mkAlert(
|
||||||
|
title: "Error deleting chat!",
|
||||||
|
message: "Error: \(responseError(error))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
|
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
|
||||||
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
||||||
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
|
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
|
||||||
@@ -819,6 +911,21 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
|
||||||
|
let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes))
|
||||||
|
if case .cmdOk = r { return true }
|
||||||
|
logger.error("apiSetUserUIThemes bad response: \(String(describing: r))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool {
|
||||||
|
let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes))
|
||||||
|
if case .cmdOk = r { return true }
|
||||||
|
logger.error("apiSetChatUIThemes bad response: \(String(describing: r))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func apiCreateUserAddress() async throws -> String {
|
func apiCreateUserAddress() async throws -> String {
|
||||||
let userId = try currentUserId("apiCreateUserAddress")
|
let userId = try currentUserId("apiCreateUserAddress")
|
||||||
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
|
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
|
||||||
@@ -866,7 +973,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
|||||||
let am = AlertManager.shared
|
let am = AlertManager.shared
|
||||||
|
|
||||||
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
||||||
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
|
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Connection error (AUTH)",
|
title: "Connection error (AUTH)",
|
||||||
message: "Sender may have deleted the connection request."
|
message: "Sender may have deleted the connection request."
|
||||||
@@ -877,7 +984,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
|||||||
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Error accepting contact request",
|
title: "Error accepting contact request",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -903,7 +1010,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
|
|||||||
return (fileTransferMeta, nil)
|
return (fileTransferMeta, nil)
|
||||||
} else {
|
} else {
|
||||||
logger.error("uploadStandaloneFile error: \(String(describing: r))")
|
logger.error("uploadStandaloneFile error: \(String(describing: r))")
|
||||||
return (nil, String(describing: r))
|
return (nil, responseError(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +1020,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
|
|||||||
return (rcvFileTransfer, nil)
|
return (rcvFileTransfer, nil)
|
||||||
} else {
|
} else {
|
||||||
logger.error("downloadStandaloneFile error: \(String(describing: r))")
|
logger.error("downloadStandaloneFile error: \(String(describing: r))")
|
||||||
return (nil, String(describing: r))
|
return (nil, responseError(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,7 +1100,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
|
|||||||
if !auto {
|
if !auto {
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Error receiving file",
|
title: "Error receiving file",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1060,18 +1167,9 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||||
switch r {
|
if let alert = getNetworkErrorAlert(r) {
|
||||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
return mkAlert(title: alert.title, message: alert.message)
|
||||||
return mkAlert(
|
} else {
|
||||||
title: "Connection timeout",
|
|
||||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
|
||||||
)
|
|
||||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
|
||||||
return mkAlert(
|
|
||||||
title: "Connection error",
|
|
||||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1079,7 +1177,17 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
|||||||
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
||||||
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
||||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||||
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
|
await MainActor.run {
|
||||||
|
ChatModel.shared.replaceChat(contactRequest.id, chat)
|
||||||
|
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
|
||||||
|
}
|
||||||
|
if contact.sndReady {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
dismissAllSheets(animated: true) {
|
||||||
|
ItemsModel.shared.loadOpenChat(chat.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,12 +1228,18 @@ func apiEndCall(_ contact: Contact) async throws {
|
|||||||
try await sendCommandOkResp(.apiEndCall(contact: contact))
|
try await sendCommandOkResp(.apiEndCall(contact: contact))
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiGetCallInvitations() throws -> [RcvCallInvitation] {
|
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
|
||||||
let r = chatSendCmdSync(.apiGetCallInvitations)
|
let r = chatSendCmdSync(.apiGetCallInvitations)
|
||||||
if case let .callInvitations(invs) = r { return invs }
|
if case let .callInvitations(invs) = r { return invs }
|
||||||
throw r
|
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 {
|
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
||||||
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
|
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
|
||||||
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
|
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
|
||||||
@@ -1175,7 +1289,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
|||||||
do {
|
do {
|
||||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||||
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
await ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1330,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
|
|||||||
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
|
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
|
||||||
switch r {
|
switch r {
|
||||||
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
|
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
|
||||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
|
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
|
||||||
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
|
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
|
||||||
default: throw r
|
default: throw r
|
||||||
}
|
}
|
||||||
@@ -1265,7 +1379,7 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
|
|||||||
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
|
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
|
||||||
return ChatModel.shared.chats
|
return ChatModel.shared.chats
|
||||||
.compactMap{ $0.chatInfo.contact }
|
.compactMap{ $0.chatInfo.contact }
|
||||||
.filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) }
|
.filter{ c in c.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) }
|
||||||
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
|
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,6 +1436,26 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
|
||||||
|
let userId = try currentUserId("getAgentSubsTotal")
|
||||||
|
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
|
||||||
|
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
|
||||||
|
logger.error("getAgentSubsTotal error: \(String(describing: r))")
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAgentServersSummary() throws -> PresentedServersSummary {
|
||||||
|
let userId = try currentUserId("getAgentServersSummary")
|
||||||
|
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
|
||||||
|
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
|
||||||
|
logger.error("getAgentServersSummary error: \(String(describing: r))")
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAgentServersStats() async throws {
|
||||||
|
try await sendCommandOkResp(.resetAgentServersStats)
|
||||||
|
}
|
||||||
|
|
||||||
private func currentUserId(_ funcName: String) throws -> Int64 {
|
private func currentUserId(_ funcName: String) throws -> Int64 {
|
||||||
if let userId = ChatModel.shared.currentUser?.userId {
|
if let userId = ChatModel.shared.currentUser?.userId {
|
||||||
return userId
|
return userId
|
||||||
@@ -1341,8 +1475,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
|
|||||||
if encryptionStartedDefault.get() {
|
if encryptionStartedDefault.get() {
|
||||||
encryptionStartedDefault.set(false)
|
encryptionStartedDefault.set(false)
|
||||||
}
|
}
|
||||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path)
|
||||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
|
||||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||||
m.chatInitialized = true
|
m.chatInitialized = true
|
||||||
m.currentUser = try apiGetActiveUser()
|
m.currentUser = try apiGetActiveUser()
|
||||||
@@ -1394,15 +1527,16 @@ func startChat(refreshInvitations: Bool = true) throws {
|
|||||||
logger.debug("startChat")
|
logger.debug("startChat")
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
try setNetworkConfig(getNetCfg())
|
try setNetworkConfig(getNetCfg())
|
||||||
let justStarted = try apiStartChat()
|
let chatRunning = try apiCheckChatRunning()
|
||||||
m.users = try listUsers()
|
m.users = try listUsers()
|
||||||
if justStarted {
|
if !chatRunning {
|
||||||
try getUserChatData()
|
try getUserChatData()
|
||||||
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
||||||
if (refreshInvitations) {
|
if (refreshInvitations) {
|
||||||
try refreshCallInvitations()
|
Task { try await refreshCallInvitations() }
|
||||||
}
|
}
|
||||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||||
|
_ = try apiStartChat()
|
||||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||||
// when it is called before startChat
|
// when it is called before startChat
|
||||||
if let token = m.deviceToken {
|
if let token = m.deviceToken {
|
||||||
@@ -1427,8 +1561,7 @@ func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
|
|||||||
logger.debug("startChatWithTemporaryDatabase")
|
logger.debug("startChatWithTemporaryDatabase")
|
||||||
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
|
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
|
||||||
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
|
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
|
||||||
try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
|
try apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl)
|
||||||
try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
|
|
||||||
_ = try apiStartChat(ctrl: ctrl)
|
_ = try apiStartChat(ctrl: ctrl)
|
||||||
return migrationActiveUser
|
return migrationActiveUser
|
||||||
}
|
}
|
||||||
@@ -1474,7 +1607,7 @@ func getUserChatData() throws {
|
|||||||
m.userAddress = try apiGetUserAddress()
|
m.userAddress = try apiGetUserAddress()
|
||||||
m.chatItemTTL = try getChatItemTTL()
|
m.chatItemTTL = try getChatItemTTL()
|
||||||
let chats = try apiGetChats()
|
let chats = try apiGetChats()
|
||||||
m.chats = chats.map { Chat.init($0) }
|
m.updateChats(chats)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getUserChatDataAsync() async throws {
|
private func getUserChatDataAsync() async throws {
|
||||||
@@ -1486,12 +1619,12 @@ private func getUserChatDataAsync() async throws {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.userAddress = userAddress
|
m.userAddress = userAddress
|
||||||
m.chatItemTTL = chatItemTTL
|
m.chatItemTTL = chatItemTTL
|
||||||
m.chats = chats.map { Chat.init($0) }
|
m.updateChats(chats)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.userAddress = nil
|
m.userAddress = nil
|
||||||
m.chats = []
|
m.updateChats([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1541,6 +1674,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
await TerminalItems.shared.add(.resp(.now, res))
|
await TerminalItems.shared.add(.resp(.now, res))
|
||||||
}
|
}
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
|
let n = NetworkModel.shared
|
||||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||||
switch res {
|
switch res {
|
||||||
case let .contactDeletedByContact(user, contact):
|
case let .contactDeletedByContact(user, contact):
|
||||||
@@ -1563,7 +1697,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
NtfManager.shared.notifyContactConnected(user, contact)
|
NtfManager.shared.notifyContactConnected(user, contact)
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.setContactNetworkStatus(contact, .connected)
|
n.setContactNetworkStatus(contact, .connected)
|
||||||
}
|
}
|
||||||
case let .contactConnecting(user, contact):
|
case let .contactConnecting(user, contact):
|
||||||
if active(user) && contact.directOrUsed {
|
if active(user) && contact.directOrUsed {
|
||||||
@@ -1575,6 +1709,19 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case let .contactSndReady(user, contact):
|
||||||
|
if active(user) && contact.directOrUsed {
|
||||||
|
await MainActor.run {
|
||||||
|
m.updateContact(contact)
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
m.dismissConnReqView(conn.id)
|
||||||
|
m.removeChat(conn.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
n.setContactNetworkStatus(contact, .connected)
|
||||||
|
}
|
||||||
case let .receivedContactRequest(user, contactRequest):
|
case let .receivedContactRequest(user, contactRequest):
|
||||||
if active(user) {
|
if active(user) {
|
||||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||||
@@ -1607,7 +1754,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
if active(user) && m.hasChat(mergedContact.id) {
|
if active(user) && m.hasChat(mergedContact.id) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if m.chatId == mergedContact.id {
|
if m.chatId == mergedContact.id {
|
||||||
m.chatId = intoContact.id
|
ItemsModel.shared.loadOpenChat(mergedContact.id)
|
||||||
}
|
}
|
||||||
m.removeChat(mergedContact.id)
|
m.removeChat(mergedContact.id)
|
||||||
}
|
}
|
||||||
@@ -1615,46 +1762,48 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
case let .networkStatus(status, connections):
|
case let .networkStatus(status, connections):
|
||||||
// dispatch queue to synchronize access
|
// dispatch queue to synchronize access
|
||||||
networkStatusesLock.sync {
|
networkStatusesLock.sync {
|
||||||
var ns = m.networkStatuses
|
var ns = n.networkStatuses
|
||||||
// slow loop is on the background thread
|
// slow loop is on the background thread
|
||||||
for cId in connections {
|
for cId in connections {
|
||||||
ns[cId] = status
|
ns[cId] = status
|
||||||
}
|
}
|
||||||
// fast model update is on the main thread
|
// fast model update is on the main thread
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
m.networkStatuses = ns
|
n.networkStatuses = ns
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .networkStatuses(_, statuses): ()
|
case let .networkStatuses(_, statuses): ()
|
||||||
// dispatch queue to synchronize access
|
// dispatch queue to synchronize access
|
||||||
networkStatusesLock.sync {
|
networkStatusesLock.sync {
|
||||||
var ns = m.networkStatuses
|
var ns = n.networkStatuses
|
||||||
// slow loop is on the background thread
|
// slow loop is on the background thread
|
||||||
for s in statuses {
|
for s in statuses {
|
||||||
ns[s.agentConnId] = s.networkStatus
|
ns[s.agentConnId] = s.networkStatus
|
||||||
}
|
}
|
||||||
// fast model update is on the main thread
|
// fast model update is on the main thread
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
m.networkStatuses = ns
|
n.networkStatuses = ns
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .newChatItem(user, aChatItem):
|
case let .newChatItems(user, chatItems):
|
||||||
let cInfo = aChatItem.chatInfo
|
for chatItem in chatItems {
|
||||||
let cItem = aChatItem.chatItem
|
let cInfo = chatItem.chatInfo
|
||||||
await MainActor.run {
|
let cItem = chatItem.chatItem
|
||||||
if active(user) {
|
await MainActor.run {
|
||||||
m.addChatItem(cInfo, cItem)
|
if active(user) {
|
||||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
m.addChatItem(cInfo, cItem)
|
||||||
m.increaseUnreadCounter(user: user)
|
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||||
|
m.increaseUnreadCounter(user: user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if let file = cItem.autoReceiveFile() {
|
||||||
if let file = cItem.autoReceiveFile() {
|
Task {
|
||||||
Task {
|
await receiveFile(user: user, fileId: file.fileId, auto: true)
|
||||||
await receiveFile(user: user, fileId: file.fileId, auto: true)
|
}
|
||||||
|
}
|
||||||
|
if cItem.showNotification {
|
||||||
|
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if cItem.showNotification {
|
|
||||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
|
||||||
}
|
}
|
||||||
case let .chatItemStatusUpdated(user, aChatItem):
|
case let .chatItemStatusUpdated(user, aChatItem):
|
||||||
let cInfo = aChatItem.chatInfo
|
let cInfo = aChatItem.chatInfo
|
||||||
@@ -1664,10 +1813,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
}
|
}
|
||||||
if let endTask = m.messageDelivery[cItem.id] {
|
if let endTask = m.messageDelivery[cItem.id] {
|
||||||
switch cItem.meta.itemStatus {
|
switch cItem.meta.itemStatus {
|
||||||
|
case .sndNew: ()
|
||||||
case .sndSent: endTask()
|
case .sndSent: endTask()
|
||||||
|
case .sndRcvd: endTask()
|
||||||
case .sndErrorAuth: endTask()
|
case .sndErrorAuth: endTask()
|
||||||
case .sndError: endTask()
|
case .sndError: endTask()
|
||||||
default: ()
|
case .sndWarning: endTask()
|
||||||
|
case .rcvNew: ()
|
||||||
|
case .rcvRead: ()
|
||||||
|
case .invalid: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .chatItemUpdated(user, aChatItem):
|
case let .chatItemUpdated(user, aChatItem):
|
||||||
@@ -1678,21 +1832,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
case let .chatItemsDeleted(user, items, _):
|
||||||
if !active(user) {
|
if !active(user) {
|
||||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
for item in items {
|
||||||
await MainActor.run {
|
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
|
||||||
m.decreaseUnreadCounter(user: user)
|
await MainActor.run {
|
||||||
|
m.decreaseUnreadCounter(user: user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let toChatItem = toChatItem {
|
for item in items {
|
||||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
if let toChatItem = item.toChatItem {
|
||||||
} else {
|
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||||
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
|
} else {
|
||||||
|
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
||||||
@@ -1772,7 +1930,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
}
|
}
|
||||||
if let contact = memberContact {
|
if let contact = memberContact {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.setContactNetworkStatus(contact, .connected)
|
n.setContactNetworkStatus(contact, .connected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .groupUpdated(user, toGroup):
|
case let .groupUpdated(user, toGroup):
|
||||||
@@ -1814,11 +1972,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
if let aChatItem = aChatItem {
|
if let aChatItem = aChatItem {
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
}
|
}
|
||||||
case let .rcvFileError(user, aChatItem, _):
|
case let .rcvFileError(user, aChatItem, _, _):
|
||||||
if let aChatItem = aChatItem {
|
if let aChatItem = aChatItem {
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
Task { cleanupFile(aChatItem) }
|
Task { cleanupFile(aChatItem) }
|
||||||
}
|
}
|
||||||
|
case let .rcvFileWarning(user, aChatItem, _, _):
|
||||||
|
if let aChatItem = aChatItem {
|
||||||
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
|
}
|
||||||
case let .sndFileStart(user, aChatItem, _):
|
case let .sndFileStart(user, aChatItem, _):
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
case let .sndFileComplete(user, aChatItem, _):
|
case let .sndFileComplete(user, aChatItem, _):
|
||||||
@@ -1835,11 +1997,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
}
|
}
|
||||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
case let .sndFileError(user, aChatItem, _):
|
case let .sndFileError(user, aChatItem, _, _):
|
||||||
if let aChatItem = aChatItem {
|
if let aChatItem = aChatItem {
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
Task { cleanupFile(aChatItem) }
|
Task { cleanupFile(aChatItem) }
|
||||||
}
|
}
|
||||||
|
case let .sndFileWarning(user, aChatItem, _, _):
|
||||||
|
if let aChatItem = aChatItem {
|
||||||
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
|
}
|
||||||
case let .callInvitation(invitation):
|
case let .callInvitation(invitation):
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.callInvitations[invitation.contact.id] = invitation
|
m.callInvitations[invitation.contact.id] = invitation
|
||||||
@@ -1937,12 +2103,30 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
|
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
|
||||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
||||||
}
|
}
|
||||||
case .remoteCtrlStopped:
|
case let .remoteCtrlStopped(_, rcStopReason):
|
||||||
// This delay is needed to cancel the session that fails on network failure,
|
// This delay is needed to cancel the session that fails on network failure,
|
||||||
// e.g. when user did not grant permission to access local network yet.
|
// e.g. when user did not grant permission to access local network yet.
|
||||||
if let sess = m.remoteCtrlSession {
|
if let sess = m.remoteCtrlSession {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.remoteCtrlSession = nil
|
m.remoteCtrlSession = nil
|
||||||
|
dismissAllSheets() {
|
||||||
|
switch rcStopReason {
|
||||||
|
case .disconnected:
|
||||||
|
()
|
||||||
|
case .connectionFailed(.errorAgent(.RCP(.identity))):
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: "Connection with desktop stopped",
|
||||||
|
message: "This link was used with another mobile device, please create a new link on the desktop."
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Connection with desktop stopped"),
|
||||||
|
message: Text("Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers."),
|
||||||
|
primaryButton: .default(Text("Ok")),
|
||||||
|
secondaryButton: .default(Text("Copy error")) { UIPasteboard.general.string = String(describing: rcStopReason) }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if case .connected = sess.sessionState {
|
if case .connected = sess.sessionState {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
@@ -1971,12 +2155,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
|
|
||||||
func switchToLocalSession() {
|
func switchToLocalSession() {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
|
let n = NetworkModel.shared
|
||||||
m.remoteCtrlSession = nil
|
m.remoteCtrlSession = nil
|
||||||
do {
|
do {
|
||||||
m.users = try listUsers()
|
m.users = try listUsers()
|
||||||
try getUserChatData()
|
try getUserChatData()
|
||||||
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
||||||
m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.debug("error updating chat data: \(responseError(error))")
|
logger.debug("error updating chat data: \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -1999,32 +2184,42 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshCallInvitations() throws {
|
func refreshCallInvitations() async throws {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
let callInvitations = try justRefreshCallInvitations()
|
let callInvitations = try await apiGetCallInvitations()
|
||||||
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
await MainActor.run {
|
||||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
m.callInvitations = callsByChat(callInvitations)
|
||||||
m.ntfCallInvitationAction = nil
|
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
||||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
m.ntfCallInvitationAction = nil
|
||||||
activateCall(invitation)
|
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||||
|
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||||
|
activateCall(invitation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
|
func justRefreshCallInvitations() async throws {
|
||||||
let m = ChatModel.shared
|
let callInvitations = try apiGetCallInvitationsSync()
|
||||||
let callInvitations = try apiGetCallInvitations()
|
await MainActor.run {
|
||||||
m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
|
ChatModel.shared.callInvitations = callsByChat(callInvitations)
|
||||||
return callInvitations
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
|
||||||
|
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
|
||||||
|
result, inv in result[inv.contact.id] = inv
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func activateCall(_ callInvitation: RcvCallInvitation) {
|
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||||
if !callInvitation.user.showNotifications { return }
|
|
||||||
let m = ChatModel.shared
|
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
|
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
|
m.callInvitations[callInvitation.contact.id]?.callUUID = nil
|
||||||
}
|
}
|
||||||
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ private func _suspendChat(timeout: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let seSubscriber = seMessageSubscriber {
|
||||||
|
switch $0 {
|
||||||
|
case let .state(state):
|
||||||
|
switch state {
|
||||||
|
case .inactive:
|
||||||
|
if AppChatState.shared.value.inactive { activateChat() }
|
||||||
|
case .sendingMessage:
|
||||||
|
if AppChatState.shared.value.canSuspend { suspendChat() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func suspendChat() {
|
func suspendChat() {
|
||||||
suspendLockQueue.sync {
|
suspendLockQueue.sync {
|
||||||
_suspendChat(timeout: appSuspendTimeout)
|
_suspendChat(timeout: appSuspendTimeout)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct SimpleXApp: App {
|
|||||||
// so that it's computed by the time view renders, and not on event after rendering
|
// so that it's computed by the time view renders, and not on event after rendering
|
||||||
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||||
.environmentObject(chatModel)
|
.environmentObject(chatModel)
|
||||||
|
.environmentObject(AppTheme.shared)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
logger.debug("ContentView.onOpenURL: \(url)")
|
logger.debug("ContentView.onOpenURL: \(url)")
|
||||||
chatModel.appOpenUrl = url
|
chatModel.appOpenUrl = url
|
||||||
@@ -57,6 +58,7 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||||
|
AppSheetState.shared.scenePhaseActive = phase == .active
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case .background:
|
case .background:
|
||||||
// --- authentication
|
// --- authentication
|
||||||
@@ -81,9 +83,11 @@ struct SimpleXApp: App {
|
|||||||
if appState != .stopped {
|
if appState != .stopped {
|
||||||
startChatAndActivate {
|
startChatAndActivate {
|
||||||
if appState.inactive && chatModel.chatRunning == true {
|
if appState.inactive && chatModel.chatRunning == true {
|
||||||
updateChats()
|
Task {
|
||||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
await updateChats()
|
||||||
updateCallInvitations()
|
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||||
|
await updateCallInvitations()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,16 +132,16 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateChats() {
|
private func updateChats() async {
|
||||||
do {
|
do {
|
||||||
let chats = try apiGetChats()
|
let chats = try await apiGetChatsAsync()
|
||||||
chatModel.updateChats(with: chats)
|
await MainActor.run { chatModel.updateChats(chats) }
|
||||||
if let id = chatModel.chatId,
|
if let id = chatModel.chatId,
|
||||||
let chat = chatModel.getChat(id) {
|
let chat = chatModel.getChat(id) {
|
||||||
loadChat(chat: chat)
|
Task { await loadChat(chat: chat, clearItems: false) }
|
||||||
}
|
}
|
||||||
if let ncr = chatModel.ntfContactRequest {
|
if let ncr = chatModel.ntfContactRequest {
|
||||||
chatModel.ntfContactRequest = nil
|
await MainActor.run { chatModel.ntfContactRequest = nil }
|
||||||
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
||||||
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
||||||
}
|
}
|
||||||
@@ -147,9 +151,9 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCallInvitations() {
|
private func updateCallInvitations() async {
|
||||||
do {
|
do {
|
||||||
try refreshCallInvitations()
|
try await refreshCallInvitations()
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
|
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
//
|
||||||
|
// Theme.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 14.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
|
||||||
|
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
|
||||||
|
var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
|
||||||
|
|
||||||
|
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
|
||||||
|
|
||||||
|
class AppTheme: ObservableObject, Equatable {
|
||||||
|
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
|
||||||
|
|
||||||
|
var name: String
|
||||||
|
var base: DefaultTheme
|
||||||
|
@ObservedObject var colors: Colors
|
||||||
|
@ObservedObject var appColors: AppColors
|
||||||
|
@ObservedObject var wallpaper: AppWallpaper
|
||||||
|
|
||||||
|
init(name: String, base: DefaultTheme, colors: Colors, appColors: AppColors, wallpaper: AppWallpaper) {
|
||||||
|
self.name = name
|
||||||
|
self.base = base
|
||||||
|
self.colors = colors
|
||||||
|
self.appColors = appColors
|
||||||
|
self.wallpaper = wallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: AppTheme, rhs: AppTheme) -> Bool {
|
||||||
|
lhs.name == rhs.name &&
|
||||||
|
lhs.colors == rhs.colors &&
|
||||||
|
lhs.appColors == rhs.appColors &&
|
||||||
|
lhs.wallpaper == rhs.wallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromCurrentColors() {
|
||||||
|
objectWillChange.send()
|
||||||
|
name = CurrentColors.name
|
||||||
|
base = CurrentColors.base
|
||||||
|
colors.updateColorsFrom(CurrentColors.colors)
|
||||||
|
appColors.updateColorsFrom(CurrentColors.appColors)
|
||||||
|
wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThemedBackground: ViewModifier {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var grouped: Bool = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(
|
||||||
|
theme.base == DefaultTheme.SIMPLEX
|
||||||
|
? LinearGradient(
|
||||||
|
colors: [
|
||||||
|
grouped
|
||||||
|
? theme.colors.background.lighter(0.4).asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background.lighter(0.4),
|
||||||
|
grouped
|
||||||
|
? theme.colors.background.darker(0.4).asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background.darker(0.4)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
: LinearGradient(
|
||||||
|
colors: [],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
theme.base == DefaultTheme.SIMPLEX
|
||||||
|
? Color.clear
|
||||||
|
: grouped
|
||||||
|
? theme.colors.background.asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemInDarkThemeCurrently: Bool {
|
||||||
|
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
|
||||||
|
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
|
||||||
|
// Change active colors from light to dark and back based on system theme
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = UIImage(base64Encoded: image),
|
||||||
|
let filename = saveWallpaperFile(image: parsed) {
|
||||||
|
var copy = self
|
||||||
|
copy.image = nil
|
||||||
|
copy.imageFile = filename
|
||||||
|
return copy
|
||||||
|
} else {
|
||||||
|
return ThemeWallpaper()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withFilledWallpaperBase64() -> ThemeWallpaper {
|
||||||
|
let aw = toAppWallpaper()
|
||||||
|
let type = aw.type
|
||||||
|
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 { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
|
||||||
|
return ThemeWallpaper (
|
||||||
|
preset: preset,
|
||||||
|
scale: scale,
|
||||||
|
scaleType: scaleType,
|
||||||
|
background: aw.background?.toReadableHex(),
|
||||||
|
tint: aw.tint?.toReadableHex(),
|
||||||
|
image: image,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ThemeModeOverride {
|
||||||
|
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
|
||||||
|
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
|
||||||
|
let w: ThemeWallpaper
|
||||||
|
switch wallpaperType {
|
||||||
|
case let WallpaperType.preset(filename, scale):
|
||||||
|
let p = PresetWallpaper.from(filename)
|
||||||
|
w = ThemeWallpaper(
|
||||||
|
preset: filename,
|
||||||
|
scale: scale ?? wallpaper?.scale,
|
||||||
|
scaleType: nil,
|
||||||
|
background: p?.background[base]?.toReadableHex(),
|
||||||
|
tint: p?.tint[base]?.toReadableHex(),
|
||||||
|
image: nil,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
case WallpaperType.image:
|
||||||
|
w = ThemeWallpaper(
|
||||||
|
preset: nil,
|
||||||
|
scale: nil,
|
||||||
|
scaleType: WallpaperScaleType.fill,
|
||||||
|
background: Color.clear.toReadableHex(),
|
||||||
|
tint: Color.clear.toReadableHex(),
|
||||||
|
image: nil,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
w = ThemeWallpaper()
|
||||||
|
}
|
||||||
|
let wallpaper: ThemeWallpaper? = if let wallpaper {
|
||||||
|
ThemeWallpaper(
|
||||||
|
preset: wallpaper.preset,
|
||||||
|
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
|
||||||
|
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
|
||||||
|
background: wallpaper.background != w.background ? wallpaper.background : nil,
|
||||||
|
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
|
||||||
|
image: wallpaper.image,
|
||||||
|
imageFile: wallpaper.imageFile
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
return ThemeModeOverride(
|
||||||
|
mode: self.mode,
|
||||||
|
colors: ThemeColors(
|
||||||
|
primary: colors.primary != tc.primary ? colors.primary : nil,
|
||||||
|
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
|
||||||
|
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
|
||||||
|
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
|
||||||
|
background: colors.background != tc.background ? colors.background : nil,
|
||||||
|
surface: colors.surface != tc.surface ? colors.surface : nil,
|
||||||
|
title: colors.title != tc.title ? colors.title : nil,
|
||||||
|
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
|
||||||
|
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
|
||||||
|
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
|
||||||
|
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
|
||||||
|
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
|
||||||
|
),
|
||||||
|
wallpaper: wallpaper
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//
|
||||||
|
// ThemeManager.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 03.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
struct ActiveTheme: Equatable {
|
||||||
|
let name: String
|
||||||
|
let base: DefaultTheme
|
||||||
|
let colors: Colors
|
||||||
|
let appColors: AppColors
|
||||||
|
var wallpaper: AppWallpaper = AppWallpaper(background: nil, tint: nil, type: .empty)
|
||||||
|
|
||||||
|
func toAppTheme() -> AppTheme {
|
||||||
|
AppTheme(name: name, base: base, colors: colors, appColors: appColors, wallpaper: wallpaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func systemDarkThemeColors() -> (Colors, DefaultTheme) {
|
||||||
|
switch systemDarkThemeDefault.get() {
|
||||||
|
case DefaultTheme.DARK.themeName: (DarkColorPalette, DefaultTheme.DARK)
|
||||||
|
case DefaultTheme.SIMPLEX.themeName: (SimplexColorPalette, DefaultTheme.SIMPLEX)
|
||||||
|
case DefaultTheme.BLACK.themeName: (BlackColorPalette, DefaultTheme.BLACK)
|
||||||
|
default: (SimplexColorPalette, DefaultTheme.SIMPLEX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nonSystemThemeName() -> String {
|
||||||
|
let themeName = currentThemeDefault.get()
|
||||||
|
return if themeName != DefaultTheme.SYSTEM_THEME_NAME {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
systemInDarkThemeCurrently ? systemDarkThemeDefault.get() : DefaultTheme.LIGHT.themeName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||||
|
return appSettingsTheme.getTheme(defaultThemeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultActiveTheme(_ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ThemeModeOverride {
|
||||||
|
let perUserTheme = !CurrentColors.colors.isLight ? perUserTheme?.dark : perUserTheme?.light
|
||||||
|
if let perUserTheme {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
|
||||||
|
let themeName = currentThemeDefault.get()
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let defaultTheme = defaultActiveTheme(appSettingsTheme)
|
||||||
|
|
||||||
|
let baseTheme = switch nonSystemThemeName {
|
||||||
|
case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
|
||||||
|
case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
|
||||||
|
case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
|
||||||
|
case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
|
||||||
|
default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
|
||||||
|
let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
|
||||||
|
|
||||||
|
if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
|
||||||
|
return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
|
||||||
|
}
|
||||||
|
let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
|
||||||
|
PresetWallpaper.from(filename)?.colors[baseTheme.base]
|
||||||
|
} else if let wallpaper = perChatTheme?.wallpaper {
|
||||||
|
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
} else if let wallpaper = perUserTheme?.wallpaper {
|
||||||
|
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
} else {
|
||||||
|
if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
|
||||||
|
let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
|
||||||
|
return ActiveTheme(
|
||||||
|
name: themeName,
|
||||||
|
base: baseTheme.base,
|
||||||
|
colors: colors,
|
||||||
|
appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
|
||||||
|
wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
|
||||||
|
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
|
||||||
|
let wType = current.wallpaper.type
|
||||||
|
let wBackground = current.wallpaper.background
|
||||||
|
let wTint = current.wallpaper.tint
|
||||||
|
let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
|
||||||
|
nil
|
||||||
|
} else {
|
||||||
|
ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
|
||||||
|
}
|
||||||
|
return ThemeOverrides(
|
||||||
|
themeId: "",
|
||||||
|
base: current.base,
|
||||||
|
colors: ThemeColors.from(current.colors, current.appColors),
|
||||||
|
wallpaper: w
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyTheme(_ theme: String) {
|
||||||
|
currentThemeDefault.set(theme)
|
||||||
|
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
AppTheme.shared.updateFromCurrentColors()
|
||||||
|
let tint = UIColor(CurrentColors.colors.primary)
|
||||||
|
if SceneDelegate.windowStatic?.tintColor != tint {
|
||||||
|
SceneDelegate.windowStatic?.tintColor = tint
|
||||||
|
}
|
||||||
|
// applyNavigationBarColors(CurrentColors.toAppTheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func adjustWindowStyle() {
|
||||||
|
let style = switch currentThemeDefault.get() {
|
||||||
|
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
|
||||||
|
case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
|
||||||
|
default: UIUserInterfaceStyle.dark
|
||||||
|
}
|
||||||
|
if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
|
||||||
|
SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// static func applyNavigationBarColors(_ theme: AppTheme) {
|
||||||
|
// let baseColors = switch theme.base {
|
||||||
|
// case DefaultTheme.LIGHT: LightColorPaletteApp
|
||||||
|
// case DefaultTheme.DARK: DarkColorPaletteApp
|
||||||
|
// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
|
||||||
|
// case DefaultTheme.BLACK: BlackColorPaletteApp
|
||||||
|
// }
|
||||||
|
// let isDefaultColor = baseColors.title == theme.appColors.title
|
||||||
|
//
|
||||||
|
// let title = UIColor(theme.appColors.title)
|
||||||
|
// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
|
||||||
|
// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
|
||||||
|
// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
|
||||||
|
// } else {
|
||||||
|
// UINavigationBar.appearance().titleTextAttributes = nil
|
||||||
|
// UINavigationBar.appearance().largeTitleTextAttributes = nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
static func changeDarkTheme(_ theme: String) {
|
||||||
|
systemDarkThemeDefault.set(theme)
|
||||||
|
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
AppTheme.shared.updateFromCurrentColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let nonSystemThemeName = baseTheme.themeName
|
||||||
|
let pref = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||||
|
let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
|
||||||
|
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding<ThemeModeOverride>) {
|
||||||
|
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
|
||||||
|
let nonSystemThemeName = baseTheme.themeName
|
||||||
|
let pref = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
let theme = overrides.sameTheme(type, baseTheme.themeName)
|
||||||
|
var prevValue = theme ?? ThemeOverrides(base: baseTheme)
|
||||||
|
prevValue.wallpaper = if let type {
|
||||||
|
if case WallpaperType.empty = type {
|
||||||
|
nil as ThemeWallpaper?
|
||||||
|
} else {
|
||||||
|
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
applyTheme(nonSystemThemeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
|
||||||
|
let overrides = themeOverridesDefault.get()
|
||||||
|
let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
|
||||||
|
wallpaper
|
||||||
|
} else {
|
||||||
|
overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
|
||||||
|
}
|
||||||
|
guard let sameWallpaper else {
|
||||||
|
if let type {
|
||||||
|
var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
|
||||||
|
w.scale = nil
|
||||||
|
w.scaleType = nil
|
||||||
|
w.background = nil
|
||||||
|
w.tint = nil
|
||||||
|
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
|
||||||
|
} else {
|
||||||
|
// Make an empty wallpaper to override any top level ones
|
||||||
|
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var type = sameWallpaper.toAppWallpaper().type
|
||||||
|
if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
|
||||||
|
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
|
||||||
|
if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
|
||||||
|
type = WallpaperType.image(filename, scale, scaleType)
|
||||||
|
} else {
|
||||||
|
logger.error("Error while copying wallpaper from global overrides to chat overrides")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
var w = ThemeWallpaper.from(type, nil, nil)
|
||||||
|
w.scale = nil
|
||||||
|
w.scaleType = nil
|
||||||
|
w.background = nil
|
||||||
|
w.tint = nil
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper = w
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding<ThemeModeOverride>) {
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
prevValue.wallpaper = if let type {
|
||||||
|
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let wallpaper = theme.wallpaper?.importFromString()
|
||||||
|
let nonSystemThemeName = theme.base.themeName
|
||||||
|
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
|
||||||
|
if let imageFile = prevValue.wallpaper?.imageFile {
|
||||||
|
try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
|
||||||
|
}
|
||||||
|
prevValue.base = theme.base
|
||||||
|
prevValue.colors = theme.colors
|
||||||
|
prevValue.wallpaper = wallpaper
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
currentThemeDefault.set(nonSystemThemeName)
|
||||||
|
var currentThemeIds = currentThemeIdsDefault.get()
|
||||||
|
currentThemeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(currentThemeIds)
|
||||||
|
applyTheme(nonSystemThemeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
|
||||||
|
var prevValue = overrides.getTheme(themeId)
|
||||||
|
else { return }
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper?.background = nil
|
||||||
|
prevValue.wallpaper?.tint = nil
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resetAllThemeColors(_ pref: Binding<ThemeModeOverride>) {
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper?.background = nil
|
||||||
|
prevValue.wallpaper?.tint = nil
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func removeTheme(_ themeId: String?) {
|
||||||
|
var themes = themeOverridesDefault.get().map { $0 }
|
||||||
|
themes.removeAll(where: { $0.themeId == themeId })
|
||||||
|
themeOverridesDefault.set(themes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ struct ActiveCallView: View {
|
|||||||
case .ended:
|
case .ended:
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
call.callState = .ended
|
call.callState = .ended
|
||||||
if let uuid = call.callkitUUID {
|
if let uuid = call.callUUID {
|
||||||
CallController.shared.endCall(callUUID: uuid)
|
CallController.shared.endCall(callUUID: uuid)
|
||||||
}
|
}
|
||||||
case .ok:
|
case .ok:
|
||||||
@@ -382,7 +382,7 @@ struct ActiveCallOverlay: View {
|
|||||||
private func endCallButton() -> some View {
|
private func endCallButton() -> some View {
|
||||||
let cc = CallController.shared
|
let cc = CallController.shared
|
||||||
return callButton("phone.down.fill", width: 60, height: 60) {
|
return callButton("phone.down.fill", width: 60, height: 60) {
|
||||||
if let uuid = call.callkitUUID {
|
if let uuid = call.callUUID {
|
||||||
cc.endCall(callUUID: uuid)
|
cc.endCall(callUUID: uuid)
|
||||||
} else {
|
} else {
|
||||||
cc.endCall(call: call) {}
|
cc.endCall(call: call) {}
|
||||||
@@ -462,9 +462,9 @@ struct ActiveCallOverlay: View {
|
|||||||
struct ActiveCallOverlay_Previews: PreviewProvider {
|
struct ActiveCallOverlay_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group{
|
Group{
|
||||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||||
.background(.black)
|
.background(.black)
|
||||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
||||||
.background(.black)
|
.background(.black)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// MPVolumeView.swift
|
// MPVolumeView.swift
|
||||||
// SimpleX (iOS)
|
// SimpleX (iOS)
|
||||||
//
|
//
|
||||||
// Created by Stanislav on 24.04.2024.
|
// Created by Avently on 24.04.2024.
|
||||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||||
logger.debug("CallController.provider CXStartCallAction")
|
logger.debug("CallController.provider CXStartCallAction")
|
||||||
if callManager.startOutgoingCall(callUUID: action.callUUID) {
|
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
||||||
} else {
|
} else {
|
||||||
@@ -61,12 +61,30 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||||
logger.debug("CallController.provider CXAnswerCallAction")
|
logger.debug("CallController.provider CXAnswerCallAction")
|
||||||
if callManager.answerIncomingCall(callUUID: action.callUUID) {
|
Task {
|
||||||
// WebRTC call should be in connected state to fulfill.
|
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
|
||||||
// Otherwise no audio and mic working on lockscreen
|
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
|
||||||
fulfillOnConnect = action
|
if !chatIsReady {
|
||||||
} else {
|
action.fail()
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +93,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
// Should be nil here if connection was in connected state
|
// Should be nil here if connection was in connected state
|
||||||
fulfillOnConnect?.fail()
|
fulfillOnConnect?.fail()
|
||||||
fulfillOnConnect = nil
|
fulfillOnConnect = nil
|
||||||
callManager.endCall(callUUID: action.callUUID) { ok in
|
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
|
||||||
if ok {
|
if ok {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
} else {
|
||||||
@@ -86,7 +104,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
|
|
||||||
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||||
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
|
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
} else {
|
||||||
action.fail()
|
action.fail()
|
||||||
@@ -156,6 +174,19 @@ 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:)
|
@objc(pushRegistry:didUpdatePushCredentials:forType:)
|
||||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||||
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
||||||
@@ -171,32 +202,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
self.reportExpiredCall(payload: payload, completion)
|
self.reportExpiredCall(payload: payload, completion)
|
||||||
return
|
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
|
// Extract the call information from the push notification payload
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||||
let invitation = m.callInvitations[contactId] {
|
let displayName = payload.dictionaryPayload["displayName"] as? String,
|
||||||
let update = self.cxCallUpdate(invitation: invitation)
|
let callUUID = payload.dictionaryPayload["callUUID"] as? String,
|
||||||
if let uuid = invitation.callkitUUID {
|
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 {
|
||||||
logger.debug("CallController: report pushkit call via CallKit")
|
logger.debug("CallController: report pushkit call via CallKit")
|
||||||
let update = self.cxCallUpdate(invitation: invitation)
|
|
||||||
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||||
if error != nil {
|
if error != nil {
|
||||||
m.callInvitations.removeValue(forKey: contactId)
|
m.callInvitations.removeValue(forKey: contactId)
|
||||||
@@ -205,11 +223,31 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
logger.debug("CallController will expire call 1")
|
||||||
self.reportExpiredCall(update: update, completion)
|
self.reportExpiredCall(update: update, completion)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
logger.debug("CallController will expire call 2")
|
||||||
self.reportExpiredCall(payload: payload, completion)
|
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,
|
// This function fulfils the requirement to always report a call when PushKit notification is received,
|
||||||
@@ -239,8 +277,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
|
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
|
||||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||||
let update = cxCallUpdate(invitation: invitation)
|
let update = cxCallUpdate(invitation: invitation)
|
||||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||||
@@ -261,6 +299,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
return update
|
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?) {
|
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||||
logger.debug("CallController: reporting incoming call connected")
|
logger.debug("CallController: reporting incoming call connected")
|
||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
@@ -272,14 +318,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||||
logger.debug("CallController: reporting outgoing call connected")
|
logger.debug("CallController: reporting outgoing call connected")
|
||||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||||
logger.debug("CallController: reporting remote ended")
|
logger.debug("CallController: reporting remote ended")
|
||||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||||
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
||||||
activeCallInvitation = nil
|
activeCallInvitation = nil
|
||||||
@@ -288,14 +334,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func reportCallRemoteEnded(call: Call) {
|
func reportCallRemoteEnded(call: Call) {
|
||||||
logger.debug("CallController: reporting remote ended")
|
logger.debug("CallController: reporting remote ended")
|
||||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
||||||
logger.debug("CallController.startCall")
|
logger.debug("CallController.startCall")
|
||||||
let uuid = callManager.newOutgoingCall(contact, media)
|
let callUUID = callManager.newOutgoingCall(contact, media)
|
||||||
|
guard let uuid = UUID(uuidString: callUUID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
let handle = CXHandle(type: .generic, value: contact.id)
|
let handle = CXHandle(type: .generic, value: contact.id)
|
||||||
let action = CXStartCallAction(call: uuid, handle: handle)
|
let action = CXStartCallAction(call: uuid, handle: handle)
|
||||||
@@ -307,8 +356,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
update.localizedCallerName = contact.displayName
|
update.localizedCallerName = contact.displayName
|
||||||
self.provider.reportCall(with: uuid, updated: update)
|
self.provider.reportCall(with: uuid, updated: update)
|
||||||
}
|
}
|
||||||
} else if callManager.startOutgoingCall(callUUID: uuid) {
|
} else if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||||
if callManager.startOutgoingCall(callUUID: uuid) {
|
if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||||
logger.debug("CallController.startCall: call started")
|
logger.debug("CallController.startCall: call started")
|
||||||
} else {
|
} else {
|
||||||
logger.error("CallController.startCall: no active call")
|
logger.error("CallController.startCall: no active call")
|
||||||
@@ -318,8 +367,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func answerCall(invitation: RcvCallInvitation) {
|
func answerCall(invitation: RcvCallInvitation) {
|
||||||
logger.debug("CallController: answering a call")
|
logger.debug("CallController: answering a call")
|
||||||
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
|
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
requestTransaction(with: CXAnswerCallAction(call: callUUID))
|
requestTransaction(with: CXAnswerCallAction(call: uuid))
|
||||||
} else {
|
} else {
|
||||||
callManager.answerIncomingCall(invitation: invitation)
|
callManager.answerIncomingCall(invitation: invitation)
|
||||||
}
|
}
|
||||||
@@ -328,10 +377,13 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func endCall(callUUID: UUID) {
|
func endCall(callUUID: String) {
|
||||||
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
|
let uuid = UUID(uuidString: callUUID)
|
||||||
|
logger.debug("CallController: ending the call with UUID \(callUUID)")
|
||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
requestTransaction(with: CXEndCallAction(call: callUUID))
|
if let uuid {
|
||||||
|
requestTransaction(with: CXEndCallAction(call: uuid))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
callManager.endCall(callUUID: callUUID) { ok in
|
callManager.endCall(callUUID: callUUID) { ok in
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ import Foundation
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
class CallManager {
|
class CallManager {
|
||||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
|
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
|
||||||
let uuid = UUID()
|
let uuid = UUID().uuidString.lowercased()
|
||||||
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||||
call.speakerEnabled = media == .video
|
call.speakerEnabled = media == .video
|
||||||
ChatModel.shared.activeCall = call
|
ChatModel.shared.activeCall = call
|
||||||
return uuid
|
return uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
func startOutgoingCall(callUUID: UUID) -> Bool {
|
func startOutgoingCall(callUUID: String) -> Bool {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
if let call = m.activeCall, call.callUUID == callUUID {
|
||||||
m.showCallView = true
|
m.showCallView = true
|
||||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||||
return true
|
return true
|
||||||
@@ -28,7 +28,7 @@ class CallManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func answerIncomingCall(callUUID: UUID) -> Bool {
|
func answerIncomingCall(callUUID: String) -> Bool {
|
||||||
if let invitation = getCallInvitation(callUUID) {
|
if let invitation = getCallInvitation(callUUID) {
|
||||||
answerIncomingCall(invitation: invitation)
|
answerIncomingCall(invitation: invitation)
|
||||||
return true
|
return true
|
||||||
@@ -42,7 +42,7 @@ class CallManager {
|
|||||||
let call = Call(
|
let call = Call(
|
||||||
direction: .incoming,
|
direction: .incoming,
|
||||||
contact: invitation.contact,
|
contact: invitation.contact,
|
||||||
callkitUUID: invitation.callkitUUID,
|
callUUID: invitation.callUUID,
|
||||||
callState: .invitationAccepted,
|
callState: .invitationAccepted,
|
||||||
localMedia: invitation.callType.media,
|
localMedia: invitation.callType.media,
|
||||||
sharedKey: invitation.sharedKey
|
sharedKey: invitation.sharedKey
|
||||||
@@ -68,8 +68,8 @@ class CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
func enableMedia(media: CallMediaType, enable: Bool, callUUID: String) -> Bool {
|
||||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||||
return true
|
return true
|
||||||
@@ -77,8 +77,8 @@ class CallManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
|
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
|
||||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||||
endCall(call: call) { completed(true) }
|
endCall(call: call) { completed(true) }
|
||||||
} else if let invitation = getCallInvitation(callUUID) {
|
} else if let invitation = getCallInvitation(callUUID) {
|
||||||
endCall(invitation: invitation) { completed(true) }
|
endCall(invitation: invitation) { completed(true) }
|
||||||
@@ -126,8 +126,8 @@ class CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
|
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
|
||||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
|
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
|
||||||
return invitation
|
return invitation
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct IncomingCallView: View {
|
struct IncomingCallView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var cc = CallController.shared
|
@ObservedObject var cc = CallController.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -43,7 +44,7 @@ struct IncomingCallView: View {
|
|||||||
cc.endCall(invitation: invitation)
|
cc.endCall(invitation: invitation)
|
||||||
}
|
}
|
||||||
|
|
||||||
callButton("Ignore", "multiply", .accentColor) {
|
callButton("Ignore", "multiply", .primary) {
|
||||||
cc.activeCallInvitation = nil
|
cc.activeCallInvitation = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ struct IncomingCallView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.modifier(ThemedBackground())
|
||||||
.onAppear { dismissAllSheets() }
|
.onAppear { dismissAllSheets() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ struct IncomingCallView: View {
|
|||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 44)
|
.frame(minWidth: 44)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Call: ObservableObject, Equatable {
|
|||||||
|
|
||||||
var direction: CallDirection
|
var direction: CallDirection
|
||||||
var contact: Contact
|
var contact: Contact
|
||||||
var callkitUUID: UUID?
|
var callUUID: String?
|
||||||
var localMedia: CallMediaType
|
var localMedia: CallMediaType
|
||||||
@Published var callState: CallState
|
@Published var callState: CallState
|
||||||
@Published var localCapabilities: CallCapabilities?
|
@Published var localCapabilities: CallCapabilities?
|
||||||
@@ -33,14 +33,14 @@ class Call: ObservableObject, Equatable {
|
|||||||
init(
|
init(
|
||||||
direction: CallDirection,
|
direction: CallDirection,
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
callkitUUID: UUID?,
|
callUUID: String?,
|
||||||
callState: CallState,
|
callState: CallState,
|
||||||
localMedia: CallMediaType,
|
localMedia: CallMediaType,
|
||||||
sharedKey: String? = nil
|
sharedKey: String? = nil
|
||||||
) {
|
) {
|
||||||
self.direction = direction
|
self.direction = direction
|
||||||
self.contact = contact
|
self.contact = contact
|
||||||
self.callkitUUID = callkitUUID
|
self.callUUID = callUUID
|
||||||
self.callState = callState
|
self.callState = callState
|
||||||
self.localMedia = localMedia
|
self.localMedia = localMedia
|
||||||
self.sharedKey = sharedKey
|
self.sharedKey = sharedKey
|
||||||
|
|||||||
@@ -411,6 +411,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
}
|
}
|
||||||
|
|
||||||
func endCall() {
|
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() {
|
||||||
guard let call = activeCall.wrappedValue else { return }
|
guard let call = activeCall.wrappedValue else { return }
|
||||||
logger.debug("WebRTCClient: ending the call")
|
logger.debug("WebRTCClient: ending the call")
|
||||||
activeCall.wrappedValue = nil
|
activeCall.wrappedValue = nil
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
|
||||||
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
|
|
||||||
struct ChatInfoToolbar: View {
|
struct ChatInfoToolbar: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var imageSize: CGFloat = 32
|
var imageSize: CGFloat = 32
|
||||||
|
|
||||||
@@ -26,29 +25,29 @@ struct ChatInfoToolbar: View {
|
|||||||
ChatInfoImage(
|
ChatInfoImage(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
size: imageSize,
|
size: imageSize,
|
||||||
color: colorScheme == .dark
|
color: Color(uiColor: .tertiaryLabel)
|
||||||
? chatImageColorDark
|
|
||||||
: chatImageColorLight
|
|
||||||
)
|
)
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
VStack {
|
let t = Text(cInfo.displayName).font(.headline)
|
||||||
let t = Text(cInfo.displayName).font(.headline)
|
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
|
||||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
VStack(spacing: 0) {
|
||||||
Text(cInfo.fullName).font(.subheadline)
|
v
|
||||||
.lineLimit(1)
|
Text(cInfo.fullName).font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.top, -2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.frame(width: 220)
|
.frame(width: 220)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contactVerifiedShield: Text {
|
private var contactVerifiedShield: Text {
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.baselineOffset(1)
|
.baselineOffset(1)
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
}
|
}
|
||||||
@@ -57,5 +56,6 @@ struct ChatInfoToolbar: View {
|
|||||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||||
|
.environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View {
|
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String], _ secondaryColor: Color) -> some View {
|
||||||
if servers.count > 0 {
|
if servers.count > 0 {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title).frame(width: 120, alignment: .leading)
|
Text(title).frame(width: 120, alignment: .leading)
|
||||||
Button(serverHost(servers[0])) {
|
Button(serverHost(servers[0])) {
|
||||||
UIPasteboard.general.string = servers.joined(separator: ";")
|
UIPasteboard.general.string = servers.joined(separator: ";")
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,16 +90,21 @@ enum SendReceipts: Identifiable, Hashable {
|
|||||||
|
|
||||||
struct ChatInfoView: View {
|
struct ChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@ObservedObject var networkModel = NetworkModel.shared
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State var contact: Contact
|
@State var contact: Contact
|
||||||
@Binding var connectionStats: ConnectionStats?
|
|
||||||
@Binding var customUserProfile: Profile?
|
|
||||||
@State var localAlias: String
|
@State var localAlias: String
|
||||||
@Binding var connectionCode: String?
|
var onSearch: () -> Void
|
||||||
|
@State private var connectionStats: ConnectionStats? = nil
|
||||||
|
@State private var customUserProfile: Profile? = nil
|
||||||
|
@State private var connectionCode: String? = nil
|
||||||
@FocusState private var aliasTextFieldFocused: Bool
|
@FocusState private var aliasTextFieldFocused: Bool
|
||||||
@State private var alert: ChatInfoViewAlert? = nil
|
@State private var alert: ChatInfoViewAlert? = nil
|
||||||
@State private var showDeleteContactActionSheet = false
|
@State private var actionSheet: SomeActionSheet? = nil
|
||||||
|
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||||
|
@State private var showConnectContactViaAddressDialog = false
|
||||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||||
@State private var sendReceiptsUserDefault = true
|
@State private var sendReceiptsUserDefault = true
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
@@ -110,7 +115,9 @@ struct ChatInfoView: View {
|
|||||||
case switchAddressAlert
|
case switchAddressAlert
|
||||||
case abortSwitchAddressAlert
|
case abortSwitchAddressAlert
|
||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case queueInfo(info: String)
|
||||||
|
case someAlert(alert: SomeAlert)
|
||||||
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -119,6 +126,8 @@ struct ChatInfoView: View {
|
|||||||
case .switchAddressAlert: return "switchAddressAlert"
|
case .switchAddressAlert: return "switchAddressAlert"
|
||||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
|
case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +148,26 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
GeometryReader { g in
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
let buttonWidth = g.size.width / 4
|
||||||
|
searchButton(width: buttonWidth)
|
||||||
|
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
muteButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: infoViewActionButtonHeight)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||||
|
|
||||||
if let customUserProfile = customUserProfile {
|
if let customUserProfile = customUserProfile {
|
||||||
Section("Incognito") {
|
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Your random profile")
|
Text("Your random profile")
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -152,16 +178,27 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
if let code = connectionCode { verifyCodeButton(code) }
|
Group {
|
||||||
contactPreferencesButton()
|
if let code = connectionCode { verifyCodeButton(code) }
|
||||||
sendReceiptsOption()
|
contactPreferencesButton()
|
||||||
if let connStats = connectionStats,
|
sendReceiptsOption()
|
||||||
connStats.ratchetSyncAllowed {
|
if let connStats = connectionStats,
|
||||||
synchronizeConnectionButton()
|
connStats.ratchetSyncAllowed {
|
||||||
|
synchronizeConnectionButton()
|
||||||
|
}
|
||||||
|
// } else if developerTools {
|
||||||
|
// synchronizeConnectionButtonForce()
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
// } else if developerTools {
|
.disabled(!contact.ready || !contact.active)
|
||||||
// synchronizeConnectionButtonForce()
|
NavigationLink {
|
||||||
// }
|
ChatWallpaperEditorSheet(chat: chat)
|
||||||
|
} label: {
|
||||||
|
Label("Chat theme", systemImage: "photo")
|
||||||
|
}
|
||||||
|
// } else if developerTools {
|
||||||
|
// synchronizeConnectionButtonForce()
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
.disabled(!contact.ready || !contact.active)
|
.disabled(!contact.ready || !contact.active)
|
||||||
|
|
||||||
@@ -181,13 +218,15 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Address")
|
Text("Address")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if contact.ready && contact.active {
|
if contact.ready && contact.active {
|
||||||
Section("Servers") {
|
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||||
networkStatusRow()
|
networkStatusRow()
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
alert = .networkStatusAlert
|
alert = .networkStatusAlert
|
||||||
@@ -209,8 +248,8 @@ struct ChatInfoView: View {
|
|||||||
|| connStats.ratchetSyncSendProhibited
|
|| connStats.ratchetSyncSendProhibited
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,12 +260,25 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section(header: Text("For console")) {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||||
|
Button ("Debug delivery") {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
@@ -235,6 +287,24 @@ struct ChatInfoView: View {
|
|||||||
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
||||||
}
|
}
|
||||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||||
|
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||||
|
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
|
||||||
|
await MainActor.run {
|
||||||
|
connectionStats = stats
|
||||||
|
customUserProfile = profile
|
||||||
|
connectionCode = code
|
||||||
|
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
|
||||||
|
chat.chatInfo = .direct(contact: ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alert(item: $alert) { alertItem in
|
.alert(item: $alert) { alertItem in
|
||||||
switch(alertItem) {
|
switch(alertItem) {
|
||||||
@@ -243,41 +313,31 @@ struct ChatInfoView: View {
|
|||||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||||
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
|
case let .someAlert(a): return a.alert
|
||||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||||
if contact.ready && contact.active {
|
.sheet(item: $sheet) {
|
||||||
return ActionSheet(
|
if #available(iOS 16.0, *) {
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
$0.content
|
||||||
buttons: [
|
.presentationDetents([.fraction(0.4)])
|
||||||
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
|
|
||||||
.destructive(Text("Delete")) { deleteContact(notify: false) },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
return ActionSheet(
|
$0.content
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
|
||||||
buttons: [
|
|
||||||
.destructive(Text("Delete")) { deleteContact() },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func contactInfoHeader() -> some View {
|
private func contactInfoHeader() -> some View {
|
||||||
VStack {
|
VStack(spacing: 8) {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.padding(.top, 12)
|
.padding(.vertical, 12)
|
||||||
.padding()
|
|
||||||
if contact.verified {
|
if contact.verified {
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "checkmark.shield"))
|
Text(Image(systemName: "checkmark.shield"))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text(contact.profile.displayName)
|
+ Text(contact.profile.displayName)
|
||||||
@@ -317,7 +377,7 @@ struct ChatInfoView: View {
|
|||||||
setContactAlias()
|
setContactAlias()
|
||||||
}
|
}
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setContactAlias() {
|
private func setContactAlias() {
|
||||||
@@ -334,6 +394,25 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func searchButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
|
||||||
|
dismiss()
|
||||||
|
onSearch()
|
||||||
|
}
|
||||||
|
.disabled(!contact.ready || chat.chatItems.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func muteButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(
|
||||||
|
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
|
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
||||||
|
width: width
|
||||||
|
) {
|
||||||
|
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||||
|
}
|
||||||
|
.disabled(!contact.ready || !contact.active)
|
||||||
|
}
|
||||||
|
|
||||||
private func verifyCodeButton(_ code: String) -> some View {
|
private func verifyCodeButton(_ code: String) -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
VerifyCodeView(
|
VerifyCodeView(
|
||||||
@@ -355,6 +434,7 @@ struct ChatInfoView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("Security code")
|
.navigationTitle("Security code")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
contact.verified ? "View security code" : "Verify security code",
|
contact.verified ? "View security code" : "Verify security code",
|
||||||
@@ -371,6 +451,7 @@ struct ChatInfoView: View {
|
|||||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Contact preferences")
|
.navigationBarTitle("Contact preferences")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Contact preferences", systemImage: "switch.2")
|
Label("Contact preferences", systemImage: "switch.2")
|
||||||
@@ -419,27 +500,34 @@ struct ChatInfoView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Network status")
|
Text("Network status")
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(chatModel.contactNetworkStatus(contact).statusString)
|
Text(networkModel.contactNetworkStatus(contact).statusString)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
serverImage()
|
serverImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func serverImage() -> some View {
|
private func serverImage() -> some View {
|
||||||
let status = chatModel.contactNetworkStatus(contact)
|
let status = networkModel.contactNetworkStatus(contact)
|
||||||
return Image(systemName: status.imageName)
|
return Image(systemName: status.imageName)
|
||||||
.foregroundColor(status == .connected ? .green : .secondary)
|
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteContactButton() -> some View {
|
private func deleteContactButton() -> some View {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showDeleteContactActionSheet = true
|
deleteContactDialog(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
dismissToChatList: true,
|
||||||
|
showAlert: { alert = .someAlert(alert: $0) },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete contact", systemImage: "trash")
|
Label("Delete contact", systemImage: "person.badge.minus")
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,25 +541,6 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteContact(notify: Bool? = nil) {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
|
|
||||||
await MainActor.run {
|
|
||||||
dismiss()
|
|
||||||
chatModel.chatId = nil
|
|
||||||
chatModel.removeChat(chat.chatInfo.id)
|
|
||||||
}
|
|
||||||
} catch let error {
|
|
||||||
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
|
|
||||||
let a = getErrorAlert(error, "Error deleting contact")
|
|
||||||
await MainActor.run {
|
|
||||||
alert = .error(title: a.title, error: a.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearChatAlert() -> Alert {
|
private func clearChatAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Clear conversation?"),
|
title: Text("Clear conversation?"),
|
||||||
@@ -489,7 +558,7 @@ struct ChatInfoView: View {
|
|||||||
private func networkStatusAlert() -> Alert {
|
private func networkStatusAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Network status"),
|
title: Text("Network status"),
|
||||||
message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
|
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +619,302 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AudioCallButton: View {
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var width: CGFloat
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CallButton(
|
||||||
|
chat: chat,
|
||||||
|
contact: contact,
|
||||||
|
image: "phone.fill",
|
||||||
|
title: "call",
|
||||||
|
mediaType: .audio,
|
||||||
|
width: width,
|
||||||
|
showAlert: showAlert
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoButton: View {
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var width: CGFloat
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CallButton(
|
||||||
|
chat: chat,
|
||||||
|
contact: contact,
|
||||||
|
image: "video.fill",
|
||||||
|
title: "video",
|
||||||
|
mediaType: .video,
|
||||||
|
width: width,
|
||||||
|
showAlert: showAlert
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CallButton: View {
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var image: String
|
||||||
|
var title: LocalizedStringKey
|
||||||
|
var mediaType: CallMediaType
|
||||||
|
var width: CGFloat
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if contact.nextSendGrpInv {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call contact",
|
||||||
|
message: "Send message to enable calls."
|
||||||
|
),
|
||||||
|
id: "can't call contact, send message"
|
||||||
|
))
|
||||||
|
} else if !contact.active {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call contact",
|
||||||
|
message: "Contact is deleted."
|
||||||
|
),
|
||||||
|
id: "can't call contact, contact deleted"
|
||||||
|
))
|
||||||
|
} else if !contact.ready {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call contact",
|
||||||
|
message: "Connecting to contact, please wait or check later!"
|
||||||
|
),
|
||||||
|
id: "can't call contact, contact not ready"
|
||||||
|
))
|
||||||
|
} else if !chat.chatInfo.featureEnabled(.calls) {
|
||||||
|
switch chat.chatInfo.showEnableCallsAlert {
|
||||||
|
case .userEnable:
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: Alert(
|
||||||
|
title: Text("Allow calls?"),
|
||||||
|
message: Text("You need to allow your contact to call to be able to call them."),
|
||||||
|
primaryButton: .default(Text("Allow")) {
|
||||||
|
allowFeatureToContact(contact, .calls)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
),
|
||||||
|
id: "allow calls"
|
||||||
|
))
|
||||||
|
case .askContact:
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Calls prohibited!",
|
||||||
|
message: "Please ask your contact to enable calls."
|
||||||
|
),
|
||||||
|
id: "calls prohibited, ask contact"
|
||||||
|
))
|
||||||
|
case .other:
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Calls prohibited!",
|
||||||
|
message: "Please check yours and your contact preferences."
|
||||||
|
)
|
||||||
|
, id: "calls prohibited, other"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(title: "Can't call contact"),
|
||||||
|
id: "can't call contact"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(ChatModel.shared.activeCall != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let infoViewActionButtonHeight: CGFloat = 60
|
||||||
|
|
||||||
|
struct InfoViewButton: View {
|
||||||
|
var image: String
|
||||||
|
var title: LocalizedStringKey
|
||||||
|
var disabledLook: Bool = false
|
||||||
|
var width: CGFloat
|
||||||
|
var action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.cornerRadius(10.0)
|
||||||
|
.frame(width: width, height: infoViewActionButtonHeight)
|
||||||
|
.disabled(disabledLook)
|
||||||
|
.onTapGesture(perform: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatWallpaperEditorSheet: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@State private var globalThemeUsed: Bool = false
|
||||||
|
@State var chat: Chat
|
||||||
|
@State private var themes: ThemeModeOverrides
|
||||||
|
|
||||||
|
init(chat: Chat) {
|
||||||
|
self.chat = chat
|
||||||
|
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else {
|
||||||
|
ThemeModeOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let preferred = themes.preferredMode(!theme.colors.isLight)
|
||||||
|
let initialTheme = preferred ?? ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
ChatWallpaperEditor(
|
||||||
|
initialTheme: initialTheme,
|
||||||
|
themeModeOverride: initialTheme,
|
||||||
|
applyToMode: themes.light == themes.dark ? nil : initialTheme.mode,
|
||||||
|
globalThemeUsed: $globalThemeUsed,
|
||||||
|
save: { applyToMode, newTheme in
|
||||||
|
await save(applyToMode, newTheme, $chat)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.navigationTitle("Chat theme")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
globalThemeUsed = preferred == nil
|
||||||
|
}
|
||||||
|
.onChange(of: theme.base.mode) { _ in
|
||||||
|
globalThemeUsed = themesFromChat(chat).preferredMode(!theme.colors.isLight) == nil
|
||||||
|
}
|
||||||
|
.onChange(of: ChatModel.shared.chatId) { _ in
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
|
||||||
|
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else {
|
||||||
|
ThemeModeOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var updateBackendTask: Task = Task {}
|
||||||
|
private func save(
|
||||||
|
_ applyToMode: DefaultThemeMode?,
|
||||||
|
_ newTheme: ThemeModeOverride?,
|
||||||
|
_ chat: Binding<Chat>
|
||||||
|
) async {
|
||||||
|
let unchangedThemes: ThemeModeOverrides = themesFromChat(chat.wrappedValue)
|
||||||
|
var wallpaperFiles = Set([unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile])
|
||||||
|
var changedThemes: ThemeModeOverrides? = unchangedThemes
|
||||||
|
let light: ThemeModeOverride? = if let newTheme {
|
||||||
|
ThemeModeOverride(mode: DefaultThemeMode.light, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let dark: ThemeModeOverride? = if let newTheme {
|
||||||
|
ThemeModeOverride(mode: DefaultThemeMode.dark, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let applyToMode {
|
||||||
|
switch applyToMode {
|
||||||
|
case DefaultThemeMode.light:
|
||||||
|
changedThemes?.light = light
|
||||||
|
case DefaultThemeMode.dark:
|
||||||
|
changedThemes?.dark = dark
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changedThemes?.light = light
|
||||||
|
changedThemes?.dark = dark
|
||||||
|
}
|
||||||
|
if changedThemes?.light != nil || changedThemes?.dark != nil {
|
||||||
|
let light = changedThemes?.light
|
||||||
|
let dark = changedThemes?.dark
|
||||||
|
let currentMode = CurrentColors.base.mode
|
||||||
|
// same image file for both modes, copy image to make them as different files
|
||||||
|
if var light, var dark, let lightWallpaper = light.wallpaper, let darkWallpaper = dark.wallpaper, let lightImageFile = lightWallpaper.imageFile, let darkImageFile = darkWallpaper.imageFile, lightWallpaper.imageFile == darkWallpaper.imageFile {
|
||||||
|
let imageFile = if currentMode == DefaultThemeMode.light {
|
||||||
|
darkImageFile
|
||||||
|
} else {
|
||||||
|
lightImageFile
|
||||||
|
}
|
||||||
|
let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile))
|
||||||
|
if currentMode == DefaultThemeMode.light {
|
||||||
|
dark.wallpaper?.imageFile = filePath
|
||||||
|
changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark)
|
||||||
|
} else {
|
||||||
|
light.wallpaper?.imageFile = filePath
|
||||||
|
changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changedThemes = nil
|
||||||
|
}
|
||||||
|
wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile)
|
||||||
|
wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile)
|
||||||
|
wallpaperFiles.forEach(removeWallpaperFile)
|
||||||
|
|
||||||
|
let changedThemesConstant = changedThemes
|
||||||
|
ChatWallpaperEditorSheet.updateBackendTask.cancel()
|
||||||
|
ChatWallpaperEditorSheet.updateBackendTask = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 300_000000)
|
||||||
|
if await apiSetChatUIThemes(chatId: chat.id, themes: changedThemesConstant) {
|
||||||
|
if case var ChatInfo.direct(contact) = chat.wrappedValue.chatInfo {
|
||||||
|
contact.uiThemes = changedThemesConstant
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.updateChatInfo(ChatInfo.direct(contact: contact))
|
||||||
|
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
|
||||||
|
themes = themesFromChat(chat.wrappedValue)
|
||||||
|
}
|
||||||
|
} else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
|
||||||
|
groupInfo.uiThemes = changedThemesConstant
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
|
||||||
|
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
|
||||||
|
themes = themesFromChat(chat.wrappedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// canceled task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Change receiving address?"),
|
title: Text("Change receiving address?"),
|
||||||
@@ -577,15 +942,238 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queueInfoText(_ info: (RcvMsgInfo?, ServerQueueInfo)) -> String {
|
||||||
|
let (rcvMsgInfo, qInfo) = info
|
||||||
|
var msgInfo: String
|
||||||
|
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
|
||||||
|
return String.localizedStringWithFormat(NSLocalizedString("server queue info: %@\n\nlast received msg: %@", comment: "queue info"), encodeJSON(qInfo), msgInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queueInfoAlert(_ info: String) -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Message queue info"),
|
||||||
|
message: Text(info),
|
||||||
|
primaryButton: .default(Text("Ok")),
|
||||||
|
secondaryButton: .default(Text("Copy")) { UIPasteboard.general.string = info }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteContactDialog(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
dismissToChatList: Bool,
|
||||||
|
showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
showActionSheet: @escaping (SomeActionSheet) -> Void,
|
||||||
|
showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
|
||||||
|
) {
|
||||||
|
if contact.sndReady && contact.active && !contact.chatDeleted {
|
||||||
|
deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent)
|
||||||
|
} else if contact.sndReady && contact.active && contact.chatDeleted {
|
||||||
|
deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet)
|
||||||
|
} else { // !(contact.sndReady && contact.active)
|
||||||
|
deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactOrConversationDialog(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
_ showActionSheet: @escaping (SomeActionSheet) -> Void,
|
||||||
|
_ showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
|
||||||
|
) {
|
||||||
|
showActionSheet(SomeActionSheet(
|
||||||
|
actionSheet: ActionSheet(
|
||||||
|
title: Text("Delete contact?"),
|
||||||
|
buttons: [
|
||||||
|
.destructive(Text("Only delete conversation")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.destructive(Text("Delete contact")) {
|
||||||
|
showSheetContent(SomeSheet(
|
||||||
|
content: { AnyView(
|
||||||
|
DeleteActiveContactDialog(
|
||||||
|
chat: chat,
|
||||||
|
contact: contact,
|
||||||
|
dismissToChatList: dismissToChatList,
|
||||||
|
showAlert: showAlert
|
||||||
|
)
|
||||||
|
) },
|
||||||
|
id: "DeleteActiveContactDialog"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
.cancel()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
id: "deleteContactOrConversationDialog"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactMaybeErrorAlert(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
chatDeleteMode: ChatDeleteMode,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
|
||||||
|
if let alert = alert_ {
|
||||||
|
showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
|
||||||
|
} else {
|
||||||
|
if dismissToChatList {
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.chatId = nil
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
dismissAllSheets(animated: true) {
|
||||||
|
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
|
||||||
|
AlertManager.shared.showAlert(deleteConversationNotice(contact))
|
||||||
|
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
|
||||||
|
AlertManager.shared.showAlert(deleteContactNotice(contact))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
|
||||||
|
showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice"))
|
||||||
|
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
|
||||||
|
showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteConversationNotice(_ contact: Contact) -> Alert {
|
||||||
|
return Alert(
|
||||||
|
title: Text("Conversation deleted!"),
|
||||||
|
message: Text("You can send messages to \(contact.displayName) from Archived contacts."),
|
||||||
|
primaryButton: .default(Text("Don't show again")) {
|
||||||
|
showDeleteConversationNoticeDefault.set(false)
|
||||||
|
},
|
||||||
|
secondaryButton: .default(Text("Ok"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactNotice(_ contact: Contact) -> Alert {
|
||||||
|
return Alert(
|
||||||
|
title: Text("Contact deleted!"),
|
||||||
|
message: Text("You can still view conversation with \(contact.displayName) in the list of chats."),
|
||||||
|
primaryButton: .default(Text("Don't show again")) {
|
||||||
|
showDeleteContactNoticeDefault.set(false)
|
||||||
|
},
|
||||||
|
secondaryButton: .default(Text("Ok"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContactDeleteMode {
|
||||||
|
case full
|
||||||
|
case entity
|
||||||
|
|
||||||
|
public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
|
||||||
|
switch self {
|
||||||
|
case .full: .full(notify: notify)
|
||||||
|
case .entity: .entity(notify: notify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeleteActiveContactDialog: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var dismissToChatList: Bool
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
@State private var keepConversation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Toggle("Keep conversation", isOn: $keepConversation)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert)
|
||||||
|
} label: {
|
||||||
|
Text("Delete without notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert)
|
||||||
|
} label: {
|
||||||
|
Text("Delete and notify contact")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Contact will be deleted - this cannot be undone!")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contactDeleteMode: ContactDeleteMode {
|
||||||
|
keepConversation ? .entity : .full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactWithoutConversation(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
_ showActionSheet: @escaping (SomeActionSheet) -> Void
|
||||||
|
) {
|
||||||
|
showActionSheet(SomeActionSheet(
|
||||||
|
actionSheet: ActionSheet(
|
||||||
|
title: Text("Confirm contact deletion?"),
|
||||||
|
buttons: [
|
||||||
|
.destructive(Text("Delete and notify contact")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.destructive(Text("Delete without notification")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.cancel()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
id: "deleteContactWithoutConversation"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteNotReadyContact(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
_ showActionSheet: @escaping (SomeActionSheet) -> Void
|
||||||
|
) {
|
||||||
|
showActionSheet(SomeActionSheet(
|
||||||
|
actionSheet: ActionSheet(
|
||||||
|
title: Text("Confirm contact deletion?"),
|
||||||
|
buttons: [
|
||||||
|
.destructive(Text("Confirm")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.cancel()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
id: "deleteNotReadyContact"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatInfoView_Previews: PreviewProvider {
|
struct ChatInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoView(
|
ChatInfoView(
|
||||||
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
||||||
contact: Contact.sampleData,
|
contact: Contact.sampleData,
|
||||||
connectionStats: Binding.constant(nil),
|
|
||||||
customUserProfile: Binding.constant(nil),
|
|
||||||
localAlias: "",
|
localAlias: "",
|
||||||
connectionCode: Binding.constant(nil)
|
onSearch: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
class AnimatedImageView: UIView {
|
class AnimatedImageView: UIView {
|
||||||
var image: UIImage? = nil
|
var image: UIImage? = nil
|
||||||
var imageView: UIImageView? = nil
|
var imageView: UIImageView? = nil
|
||||||
|
var cMode: UIView.ContentMode = .scaleAspectFit
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
|
|||||||
fatalError("Not implemented")
|
fatalError("Not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(image: UIImage) {
|
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
|
||||||
self.init()
|
self.init()
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.cMode = contentMode
|
||||||
imageView = UIImageView(gifImage: image)
|
imageView = UIImageView(gifImage: image)
|
||||||
imageView!.contentMode = .scaleAspectFit
|
imageView!.contentMode = contentMode
|
||||||
self.addSubview(imageView!)
|
self.addSubview(imageView!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
|
|||||||
if let subview = self.subviews.first as? UIImageView {
|
if let subview = self.subviews.first as? UIImageView {
|
||||||
if image.imageData != subview.gifImage?.imageData {
|
if image.imageData != subview.gifImage?.imageData {
|
||||||
imageView = UIImageView(gifImage: image)
|
imageView = UIImageView(gifImage: image)
|
||||||
imageView!.contentMode = .scaleAspectFit
|
imageView!.contentMode = contentMode
|
||||||
self.addSubview(imageView!)
|
self.addSubview(imageView!)
|
||||||
subview.removeFromSuperview()
|
subview.removeFromSuperview()
|
||||||
}
|
}
|
||||||
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
|
|||||||
|
|
||||||
struct SwiftyGif: UIViewRepresentable {
|
struct SwiftyGif: UIViewRepresentable {
|
||||||
private let image: UIImage
|
private let image: UIImage
|
||||||
|
private let contentMode: UIView.ContentMode
|
||||||
|
|
||||||
init(image: UIImage) {
|
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.contentMode = contentMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> AnimatedImageView {
|
func makeUIView(context: Context) -> AnimatedImageView {
|
||||||
AnimatedImageView(image: image)
|
AnimatedImageView(image: image, contentMode: contentMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CICallItemView: View {
|
struct CICallItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var status: CICallStatus
|
var status: CICallStatus
|
||||||
@@ -22,7 +23,7 @@ struct CICallItemView: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case .pending:
|
case .pending:
|
||||||
if sent {
|
if sent {
|
||||||
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
acceptCallButton()
|
acceptCallButton()
|
||||||
}
|
}
|
||||||
@@ -35,7 +36,7 @@ struct CICallItemView: View {
|
|||||||
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
|
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ struct CICallItemView: View {
|
|||||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "phone.down")
|
Image(systemName: "phone.down")
|
||||||
Text(durationText(duration)).foregroundColor(.secondary)
|
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ struct CICallItemView: View {
|
|||||||
Label("Answer call", systemImage: "phone.arrow.down.left")
|
Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIChatFeatureView: View {
|
struct CIChatFeatureView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@ObservedObject var im = ItemsModel.shared
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
var feature: Feature
|
var feature: Feature
|
||||||
@@ -52,8 +54,8 @@ struct CIChatFeatureView: View {
|
|||||||
var fs: [FeatureInfo] = []
|
var fs: [FeatureInfo] = []
|
||||||
var icons: Set<String> = []
|
var icons: Set<String> = []
|
||||||
if var i = m.getChatItemIndex(chatItem) {
|
if var i = m.getChatItemIndex(chatItem) {
|
||||||
while i < m.reversedChatItems.count,
|
while i < im.reversedChatItems.count,
|
||||||
let f = featureInfo(m.reversedChatItems[i]) {
|
let f = featureInfo(im.reversedChatItems[i]) {
|
||||||
if !icons.contains(f.icon) {
|
if !icons.contains(f.icon) {
|
||||||
fs.insert(f, at: 0)
|
fs.insert(f, at: 0)
|
||||||
icons.insert(f.icon)
|
icons.insert(f.icon)
|
||||||
@@ -66,10 +68,10 @@ struct CIChatFeatureView: View {
|
|||||||
|
|
||||||
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
||||||
switch ci.content {
|
switch ci.content {
|
||||||
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
|
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
|
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
default: nil
|
default: nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,7 @@ struct CIChatFeatureView: View {
|
|||||||
if let param = f.param {
|
if let param = f.param {
|
||||||
HStack {
|
HStack {
|
||||||
i
|
i
|
||||||
chatEventText(Text(param)).lineLimit(1)
|
chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
i
|
i
|
||||||
@@ -93,7 +95,7 @@ struct CIChatFeatureView: View {
|
|||||||
Image(systemName: icon ?? feature.iconFilled)
|
Image(systemName: icon ?? feature.iconFilled)
|
||||||
.foregroundColor(iconColor)
|
.foregroundColor(iconColor)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
chatEventText(chatItem)
|
chatEventText(chatItem, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -104,6 +106,6 @@ struct CIChatFeatureView: View {
|
|||||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||||
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFeaturePreferenceView: View {
|
struct CIFeaturePreferenceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var feature: ChatFeature
|
var feature: ChatFeature
|
||||||
var allowed: FeatureAllowed
|
var allowed: FeatureAllowed
|
||||||
@@ -19,7 +20,7 @@ struct CIFeaturePreferenceView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
Image(systemName: feature.icon)
|
Image(systemName: feature.icon)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
if let ct = chat.chatInfo.contact,
|
if let ct = chat.chatInfo.contact,
|
||||||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||||
@@ -40,17 +41,17 @@ struct CIFeaturePreferenceView: View {
|
|||||||
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
||||||
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
if let acceptText {
|
if let acceptText {
|
||||||
r = r
|
r = r
|
||||||
+ Text(acceptText)
|
+ Text(acceptText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,42 +11,48 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFileView: View {
|
struct CIFileView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let file: CIFile?
|
let file: CIFile?
|
||||||
let edited: Bool
|
let edited: Bool
|
||||||
|
var smallViewSize: CGFloat?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let metaReserve = edited
|
if smallViewSize != nil {
|
||||||
? " "
|
fileIndicator()
|
||||||
: " "
|
.onTapGesture(perform: fileAction)
|
||||||
Button(action: fileAction) {
|
} else {
|
||||||
HStack(alignment: .bottom, spacing: 6) {
|
let metaReserve = edited
|
||||||
fileIndicator()
|
? " "
|
||||||
.padding(.top, 5)
|
: " "
|
||||||
.padding(.bottom, 3)
|
Button(action: fileAction) {
|
||||||
if let file = file {
|
HStack(alignment: .bottom, spacing: 6) {
|
||||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
fileIndicator()
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.padding(.top, 5)
|
||||||
Text(file.fileName)
|
.padding(.bottom, 3)
|
||||||
.lineLimit(1)
|
if let file = file {
|
||||||
.multilineTextAlignment(.leading)
|
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||||
.foregroundColor(.primary)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(prettyFileSize + metaReserve)
|
Text(file.fileName)
|
||||||
.font(.caption)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.multilineTextAlignment(.leading)
|
||||||
.multilineTextAlignment(.leading)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.foregroundColor(.secondary)
|
Text(prettyFileSize + metaReserve)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(metaReserve)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text(metaReserve)
|
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.trailing, 12)
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.disabled(!itemInteractive)
|
||||||
.padding(.bottom, 6)
|
|
||||||
.padding(.leading, 10)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
}
|
}
|
||||||
.disabled(!itemInteractive)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var itemInteractive: Bool {
|
private var itemInteractive: Bool {
|
||||||
@@ -54,16 +60,18 @@ struct CIFileView: View {
|
|||||||
switch (file.fileStatus) {
|
switch (file.fileStatus) {
|
||||||
case .sndStored: return file.fileProtocol == .local
|
case .sndStored: return file.fileProtocol == .local
|
||||||
case .sndTransfer: return false
|
case .sndTransfer: return false
|
||||||
case .sndComplete: return false
|
case .sndComplete: return true
|
||||||
case .sndCancelled: return false
|
case .sndCancelled: return false
|
||||||
case .sndError: return false
|
case .sndError: return true
|
||||||
|
case .sndWarning: return true
|
||||||
case .rcvInvitation: return true
|
case .rcvInvitation: return true
|
||||||
case .rcvAccepted: return true
|
case .rcvAccepted: return true
|
||||||
case .rcvTransfer: return false
|
case .rcvTransfer: return false
|
||||||
case .rcvAborted: return true
|
case .rcvAborted: return true
|
||||||
case .rcvComplete: return true
|
case .rcvComplete: return true
|
||||||
case .rcvCancelled: return false
|
case .rcvCancelled: return false
|
||||||
case .rcvError: return false
|
case .rcvError: return true
|
||||||
|
case .rcvWarning: return true
|
||||||
case .invalid: return false
|
case .invalid: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,11 +116,40 @@ struct CIFileView: View {
|
|||||||
if let fileSource = getLoadedFileSource(file) {
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
saveCryptoFile(fileSource)
|
saveCryptoFile(fileSource)
|
||||||
}
|
}
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
case .sndStored:
|
case .sndStored:
|
||||||
logger.debug("CIFileView fileAction - in .sndStored")
|
logger.debug("CIFileView fileAction - in .sndStored")
|
||||||
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
||||||
saveCryptoFile(fileSource)
|
saveCryptoFile(fileSource)
|
||||||
}
|
}
|
||||||
|
case .sndComplete:
|
||||||
|
logger.debug("CIFileView fileAction - in .sndComplete")
|
||||||
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
|
saveCryptoFile(fileSource)
|
||||||
|
}
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,9 +173,10 @@ struct CIFileView: View {
|
|||||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .rcvInvitation:
|
case .rcvInvitation:
|
||||||
if fileSizeValid(file) {
|
if fileSizeValid(file) {
|
||||||
fileIcon("arrow.down.doc.fill", color: .accentColor)
|
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
|
||||||
} else {
|
} else {
|
||||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||||
}
|
}
|
||||||
@@ -150,10 +188,11 @@ struct CIFileView: View {
|
|||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
case .rcvAborted:
|
case .rcvAborted:
|
||||||
fileIcon("doc.fill", color: .accentColor, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
|
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
|
||||||
case .rcvComplete: fileIcon("doc.fill")
|
case .rcvComplete: fileIcon("doc.fill")
|
||||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -162,21 +201,22 @@ struct CIFileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||||
ZStack(alignment: .center) {
|
let size = smallViewSize ?? 30
|
||||||
|
return ZStack(alignment: .center) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
if let innerIcon = innerIcon,
|
if let innerIcon = innerIcon,
|
||||||
let innerIconSize = innerIconSize {
|
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
|
||||||
Image(systemName: innerIcon)
|
Image(systemName: innerIcon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(maxHeight: 16)
|
.frame(maxHeight: 16)
|
||||||
.frame(width: innerIconSize, height: innerIconSize)
|
.frame(width: innerIconSize, height: innerIconSize)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.top, 12)
|
.padding(.top, size / 2.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIGroupInvitationView: View {
|
struct CIGroupInvitationView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var groupInvitation: CIGroupInvitation
|
var groupInvitation: CIGroupInvitation
|
||||||
@@ -42,10 +43,10 @@ struct CIGroupInvitationView: View {
|
|||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
(
|
(
|
||||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||||
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
|
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
)
|
)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ struct CIGroupInvitationView: View {
|
|||||||
(
|
(
|
||||||
groupInvitationText()
|
groupInvitationText()
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
)
|
)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
@@ -65,12 +66,11 @@ struct CIGroupInvitationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||||
.onChange(of: inProgress) { inProgress in
|
.onChange(of: inProgress) { inProgress in
|
||||||
@@ -99,7 +99,7 @@ struct CIGroupInvitationView: View {
|
|||||||
private func groupInfoView(_ action: Bool) -> some View {
|
private func groupInfoView(_ action: Bool) -> some View {
|
||||||
var color: Color
|
var color: Color
|
||||||
if action && !inProgress {
|
if action && !inProgress {
|
||||||
color = chatIncognito ? .indigo : .accentColor
|
color = chatIncognito ? .indigo : theme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
color = Color(uiColor: .tertiaryLabel)
|
color = Color(uiColor: .tertiaryLabel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,29 +11,37 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIImageView: View {
|
struct CIImageView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
let chatItem: ChatItem
|
let chatItem: ChatItem
|
||||||
let image: String
|
var preview: UIImage?
|
||||||
let maxWidth: CGFloat
|
let maxWidth: CGFloat
|
||||||
@Binding var imgWidth: CGFloat?
|
var imgWidth: CGFloat?
|
||||||
@State var scrollProxy: ScrollViewProxy?
|
var smallView: Bool = false
|
||||||
@State private var showFullScreenImage = false
|
@Binding var showFullScreenImage: Bool
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let file = chatItem.file
|
let file = chatItem.file
|
||||||
VStack(alignment: .center, spacing: 6) {
|
VStack(alignment: .center, spacing: 6) {
|
||||||
if let uiImage = getLoadedImage(file) {
|
if let uiImage = getLoadedImage(file) {
|
||||||
imageView(uiImage)
|
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||||
|
}
|
||||||
|
.if(!smallView) { view in
|
||||||
|
view.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
}
|
}
|
||||||
.onTapGesture { showFullScreenImage = true }
|
.onTapGesture { showFullScreenImage = true }
|
||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenImage = false
|
showFullScreenImage = false
|
||||||
}
|
}
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let preview {
|
||||||
let uiImage = UIImage(data: data) {
|
Group {
|
||||||
imageView(uiImage)
|
if smallView {
|
||||||
|
smallViewImageView(preview)
|
||||||
|
} else {
|
||||||
|
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
|
||||||
|
}
|
||||||
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
@@ -60,17 +68,39 @@ struct CIImageView: View {
|
|||||||
case .rcvTransfer: () // ?
|
case .rcvTransfer: () // ?
|
||||||
case .rcvComplete: () // ?
|
case .rcvComplete: () // ?
|
||||||
case .rcvCancelled: () // TODO
|
case .rcvCancelled: () // TODO
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
showFullScreenImage = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { imgWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
if img.imageData == nil {
|
if img.imageData == nil {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
@@ -82,7 +112,26 @@ struct CIImageView: View {
|
|||||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||||
|
loadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smallViewImageView(_ img: UIImage) -> some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
if img.imageData == nil {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: maxWidth, height: maxWidth)
|
||||||
|
} else {
|
||||||
|
SwiftyGif(image: img, contentMode: .scaleAspectFill)
|
||||||
|
.frame(width: maxWidth, height: maxWidth)
|
||||||
|
}
|
||||||
|
if chatItem.file?.showStatusIconInSmallView == true {
|
||||||
|
loadingIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +148,16 @@ struct CIImageView: View {
|
|||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case .sndError: fileIcon("xmark", 10, 13)
|
||||||
|
case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case .rcvTransfer: progressView()
|
case .rcvTransfer: progressView()
|
||||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case .rcvError: fileIcon("xmark", 10, 13)
|
||||||
|
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,4 +178,12 @@ struct CIImageView: View {
|
|||||||
.tint(.white)
|
.tint(.white)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||||
|
switch fileStatus {
|
||||||
|
case .rcvInvitation: true
|
||||||
|
case .rcvAborted: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CIInvalidJSONView: View {
|
struct CIInvalidJSONView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var json: String
|
var json: String
|
||||||
@State private var showJSON = false
|
@State private var showJSON = false
|
||||||
|
|
||||||
@@ -21,10 +22,9 @@ struct CIInvalidJSONView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture { showJSON = true }
|
.onTapGesture { showJSON = true }
|
||||||
.sheet(isPresented: $showJSON) {
|
.appSheet(isPresented: $showJSON) {
|
||||||
invalidJSONView(json)
|
invalidJSONView(json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,7 @@ func invalidJSONView(_ json: String) -> some View {
|
|||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIInvalidJSONView_Previews: PreviewProvider {
|
struct CIInvalidJSONView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct CILinkView: View {
|
struct CILinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview
|
let linkPreview: LinkPreview
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .center, spacing: 6) {
|
VStack(alignment: .center, spacing: 6) {
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
|
.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(linkPreview.title)
|
Text(linkPreview.title)
|
||||||
@@ -32,7 +33,7 @@ struct CILinkView: View {
|
|||||||
Text(linkPreview.uri.absoluteString)
|
Text(linkPreview.uri.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMemberCreatedContactView: View {
|
struct CIMemberCreatedContactView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -43,12 +44,12 @@ struct CIMemberCreatedContactView: View {
|
|||||||
r = r
|
r = r
|
||||||
+ Text(openText)
|
+ Text(openText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +57,11 @@ struct CIMemberCreatedContactView: View {
|
|||||||
if let member = chatItem.memberDisplayName {
|
if let member = chatItem.memberDisplayName {
|
||||||
return Text(member + " " + chatItem.content.text + " ")
|
return Text(member + " " + chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
return Text(chatItem.content.text + " ")
|
return Text(chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMetaView: View {
|
struct CIMetaView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var metaColor = Color.secondary
|
var metaColor: Color
|
||||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||||
var showStatus = true
|
var showStatus = true
|
||||||
var showEdited = true
|
var showEdited = true
|
||||||
@@ -29,24 +31,24 @@ struct CIMetaView: View {
|
|||||||
switch meta.itemStatus {
|
switch meta.itemStatus {
|
||||||
case let .sndSent(sndProgress):
|
case let .sndSent(sndProgress):
|
||||||
switch sndProgress {
|
switch sndProgress {
|
||||||
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, 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, showTimesamp: showTimestamp)
|
||||||
}
|
}
|
||||||
case let .sndRcvd(_, sndProgress):
|
case let .sndRcvd(_, sndProgress):
|
||||||
switch sndProgress {
|
switch sndProgress {
|
||||||
case .complete:
|
case .complete:
|
||||||
ZStack {
|
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: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
}
|
}
|
||||||
case .partial:
|
case .partial:
|
||||||
ZStack {
|
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: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,11 +65,13 @@ func ciMetaText(
|
|||||||
chatTTL: Int?,
|
chatTTL: Int?,
|
||||||
encrypted: Bool?,
|
encrypted: Bool?,
|
||||||
color: Color = .clear,
|
color: Color = .clear,
|
||||||
|
primaryColor: Color = .accentColor,
|
||||||
transparent: Bool = false,
|
transparent: Bool = false,
|
||||||
sent: SentCheckmark? = nil,
|
sent: SentCheckmark? = nil,
|
||||||
showStatus: Bool = true,
|
showStatus: Bool = true,
|
||||||
showEdited: Bool = true,
|
showEdited: Bool = true,
|
||||||
showViaProxy: Bool
|
showViaProxy: Bool,
|
||||||
|
showTimesamp: Bool
|
||||||
) -> Text {
|
) -> Text {
|
||||||
var r = Text("")
|
var r = Text("")
|
||||||
if showEdited, meta.itemEdited {
|
if showEdited, meta.itemEdited {
|
||||||
@@ -85,7 +89,7 @@ func ciMetaText(
|
|||||||
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
||||||
}
|
}
|
||||||
if showStatus {
|
if showStatus {
|
||||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
|
||||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
let t = Text(Image(systemName: icon)).font(.caption2)
|
||||||
let gap = Text(" ").kerning(-1.25)
|
let gap = Text(" ").kerning(-1.25)
|
||||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||||
@@ -103,7 +107,9 @@ func ciMetaText(
|
|||||||
if let enc = encrypted {
|
if let enc = encrypted {
|
||||||
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
||||||
}
|
}
|
||||||
r = r + meta.timestampText.foregroundColor(color)
|
if showTimesamp {
|
||||||
|
r = r + meta.timestampText.foregroundColor(color)
|
||||||
|
}
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,15 +118,16 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct CIMetaView_Previews: PreviewProvider {
|
struct CIMetaView_Previews: PreviewProvider {
|
||||||
|
static let metaColor = Color.secondary
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 100))
|
.previewLayout(.fixed(width: 360, height: 100))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
|
|||||||
|
|
||||||
struct CIRcvDecryptionError: View {
|
struct CIRcvDecryptionError: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
var msgDecryptError: MsgDecryptError
|
var msgDecryptError: MsgDecryptError
|
||||||
var msgCount: UInt32
|
var msgCount: UInt32
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@@ -26,7 +28,7 @@ struct CIRcvDecryptionError: View {
|
|||||||
case syncNotSupportedContactAlert
|
case syncNotSupportedContactAlert
|
||||||
case syncNotSupportedMemberAlert
|
case syncNotSupportedMemberAlert
|
||||||
case decryptionErrorAlert
|
case decryptionErrorAlert
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -61,43 +63,46 @@ struct CIRcvDecryptionError: View {
|
|||||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
||||||
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func viewBody() -> some View {
|
@ViewBuilder private func viewBody() -> some View {
|
||||||
if case let .direct(contact) = chat.chatInfo,
|
Group {
|
||||||
let contactStats = contact.activeConn?.connectionStats {
|
if case let .direct(contact) = chat.chatInfo,
|
||||||
if contactStats.ratchetSyncAllowed {
|
let contactStats = contact.activeConn?.connectionStats {
|
||||||
decryptionErrorItemFixButton(syncSupported: true) {
|
if contactStats.ratchetSyncAllowed {
|
||||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
decryptionErrorItemFixButton(syncSupported: true) {
|
||||||
|
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||||
|
}
|
||||||
|
} else if !contactStats.ratchetSyncSupported {
|
||||||
|
decryptionErrorItemFixButton(syncSupported: false) {
|
||||||
|
alert = .syncNotSupportedContactAlert
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
basicDecryptionErrorItem()
|
||||||
}
|
}
|
||||||
} else if !contactStats.ratchetSyncSupported {
|
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||||
decryptionErrorItemFixButton(syncSupported: false) {
|
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||||
alert = .syncNotSupportedContactAlert
|
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 {
|
} else {
|
||||||
basicDecryptionErrorItem()
|
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 {
|
private func basicDecryptionErrorItem() -> some View {
|
||||||
@@ -114,24 +119,22 @@ struct CIRcvDecryptionError: View {
|
|||||||
}
|
}
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text("Fix connection")
|
+ Text("Fix connection")
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,16 +145,14 @@ struct CIRcvDecryptionError: View {
|
|||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.italic()
|
.italic()
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,61 +13,51 @@ import Combine
|
|||||||
|
|
||||||
struct CIVideoView: View {
|
struct CIVideoView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
private let chatItem: ChatItem
|
private let chatItem: ChatItem
|
||||||
private let image: String
|
private let preview: UIImage?
|
||||||
@State private var duration: Int
|
@State private var duration: Int
|
||||||
@State private var progress: Int = 0
|
@State private var progress: Int = 0
|
||||||
@State private var videoPlaying: Bool = false
|
@State private var videoPlaying: Bool = false
|
||||||
private let maxWidth: CGFloat
|
private let maxWidth: CGFloat
|
||||||
@Binding private var videoWidth: CGFloat?
|
private var videoWidth: CGFloat?
|
||||||
@State private var scrollProxy: ScrollViewProxy?
|
private let smallView: Bool
|
||||||
@State private var preview: UIImage? = nil
|
|
||||||
@State private var player: AVPlayer?
|
@State private var player: AVPlayer?
|
||||||
@State private var fullPlayer: AVPlayer?
|
@State private var fullPlayer: AVPlayer?
|
||||||
@State private var url: URL?
|
@State private var url: URL?
|
||||||
@State private var urlDecrypted: URL?
|
@State private var urlDecrypted: URL?
|
||||||
@State private var decryptionInProgress: Bool = false
|
@State private var decryptionInProgress: Bool = false
|
||||||
@State private var showFullScreenPlayer = false
|
@Binding private var showFullScreenPlayer: Bool
|
||||||
@State private var timeObserver: Any? = nil
|
@State private var timeObserver: Any? = nil
|
||||||
@State private var fullScreenTimeObserver: Any? = nil
|
@State private var fullScreenTimeObserver: Any? = nil
|
||||||
@State private var publisher: AnyCancellable? = nil
|
@State private var publisher: AnyCancellable? = nil
|
||||||
|
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.image = image
|
self.preview = preview
|
||||||
self._duration = State(initialValue: duration)
|
self._duration = State(initialValue: duration)
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self._videoWidth = videoWidth
|
self.videoWidth = videoWidth
|
||||||
self.scrollProxy = scrollProxy
|
self.smallView = smallView
|
||||||
if let url = getLoadedVideo(chatItem.file) {
|
self._showFullScreenPlayer = showFullscreenPlayer
|
||||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
|
||||||
self._urlDecrypted = State(initialValue: decrypted)
|
|
||||||
if let decrypted = decrypted {
|
|
||||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
|
|
||||||
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
|
|
||||||
}
|
|
||||||
self._url = State(initialValue: url)
|
|
||||||
}
|
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
self._preview = State(initialValue: uiImage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let file = chatItem.file
|
let file = chatItem.file
|
||||||
ZStack {
|
ZStack(alignment: smallView ? .topLeading : .center) {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
|
||||||
|
smallVideoView(decrypted, file, preview)
|
||||||
|
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||||
videoView(player, decrypted, file, preview, duration)
|
videoView(player, decrypted, file, preview, duration)
|
||||||
|
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
|
||||||
|
smallVideoViewEncrypted(file, defaultPreview)
|
||||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||||
videoViewEncrypted(file, defaultPreview, duration)
|
videoViewEncrypted(file, defaultPreview, duration)
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let preview, let file {
|
||||||
let uiImage = UIImage(data: data) {
|
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||||
imageView(uiImage)
|
.onTapGesture {
|
||||||
.onTapGesture {
|
|
||||||
if let file = file {
|
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .rcvInvitation, .rcvAborted:
|
case .rcvInvitation, .rcvAborted:
|
||||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
@@ -91,21 +81,64 @@ struct CIVideoView: View {
|
|||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
durationProgress()
|
if !smallView {
|
||||||
|
durationProgress()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let file = file, showDownloadButton(file.fileStatus) {
|
if !blurred, let file, showDownloadButton(file.fileStatus) {
|
||||||
Button {
|
if !smallView {
|
||||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
Button {
|
||||||
} label: {
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
} else if !file.showStatusIconInSmallView {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill")
|
||||||
|
.onTapGesture {
|
||||||
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||||
|
if let decrypted = urlDecrypted {
|
||||||
|
fullScreenPlayer(decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
setupPlayer(chatItem.file)
|
||||||
|
}
|
||||||
|
.onChange(of: chatItem.file) { file in
|
||||||
|
// ChatItem can be changed in small view on chat list screen
|
||||||
|
setupPlayer(file)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
|
private func setupPlayer(_ file: CIFile?) {
|
||||||
|
let newUrl = getLoadedVideo(file)
|
||||||
|
if newUrl == url {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url = nil
|
||||||
|
urlDecrypted = nil
|
||||||
|
player = nil
|
||||||
|
fullPlayer = nil
|
||||||
|
if let newUrl {
|
||||||
|
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
|
||||||
|
urlDecrypted = decrypted
|
||||||
|
if let decrypted = decrypted {
|
||||||
|
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||||
|
fullPlayer = AVPlayer(url: decrypted)
|
||||||
|
}
|
||||||
|
url = newUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||||
switch fileStatus {
|
switch fileStatus {
|
||||||
case .rcvInvitation: true
|
case .rcvInvitation: true
|
||||||
case .rcvAborted: true
|
case .rcvAborted: true
|
||||||
@@ -118,11 +151,6 @@ struct CIVideoView: View {
|
|||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
imageView(defaultPreview)
|
imageView(defaultPreview)
|
||||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
|
||||||
if let decrypted = urlDecrypted {
|
|
||||||
fullScreenPlayer(decrypted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
decrypt(file: file) {
|
decrypt(file: file) {
|
||||||
showFullScreenPlayer = urlDecrypted != nil
|
showFullScreenPlayer = urlDecrypted != nil
|
||||||
@@ -131,20 +159,22 @@ struct CIVideoView: View {
|
|||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenPlayer = false
|
showFullScreenPlayer = false
|
||||||
}
|
}
|
||||||
if !decryptionInProgress {
|
if !blurred {
|
||||||
Button {
|
if !decryptionInProgress {
|
||||||
decrypt(file: file) {
|
Button {
|
||||||
if urlDecrypted != nil {
|
decrypt(file: file) {
|
||||||
videoPlaying = true
|
if urlDecrypted != nil {
|
||||||
player?.play()
|
videoPlaying = true
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||||
}
|
}
|
||||||
} label: {
|
.disabled(!canBePlayed)
|
||||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
} else {
|
||||||
|
videoDecryptionProgress()
|
||||||
}
|
}
|
||||||
.disabled(!canBePlayed)
|
|
||||||
} else {
|
|
||||||
videoDecryptionProgress()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +182,6 @@ struct CIVideoView: View {
|
|||||||
|
|
||||||
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
||||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
@@ -164,9 +193,7 @@ struct CIVideoView: View {
|
|||||||
videoPlaying = false
|
videoPlaying = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
||||||
fullScreenPlayer(url)
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
switch player.timeControlStatus {
|
switch player.timeControlStatus {
|
||||||
case .playing:
|
case .playing:
|
||||||
@@ -182,7 +209,7 @@ struct CIVideoView: View {
|
|||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenPlayer = false
|
showFullScreenPlayer = false
|
||||||
}
|
}
|
||||||
if !videoPlaying {
|
if !videoPlaying && !blurred {
|
||||||
Button {
|
Button {
|
||||||
m.stopPreviousRecPlay = url
|
m.stopPreviousRecPlay = url
|
||||||
player.play()
|
player.play()
|
||||||
@@ -192,7 +219,7 @@ struct CIVideoView: View {
|
|||||||
.disabled(!canBePlayed)
|
.disabled(!canBePlayed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
fileStatusIcon()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
addObserver(player, url)
|
addObserver(player, url)
|
||||||
@@ -204,14 +231,53 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
|
||||||
|
return ZStack(alignment: .topLeading) {
|
||||||
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
|
smallViewImageView(preview, file)
|
||||||
|
.onTapGesture {
|
||||||
|
decrypt(file: file) {
|
||||||
|
showFullScreenPlayer = urlDecrypted != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
|
if file.showStatusIconInSmallView {
|
||||||
|
// Show nothing
|
||||||
|
} else if !decryptionInProgress {
|
||||||
|
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||||
|
} else {
|
||||||
|
videoDecryptionProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
|
||||||
|
return ZStack(alignment: .topLeading) {
|
||||||
|
smallViewImageView(preview, file)
|
||||||
|
.onTapGesture {
|
||||||
|
showFullScreenPlayer = true
|
||||||
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file.showStatusIconInSmallView {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, smallView ? 0 : 4)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
|
||||||
.background(Color.black.opacity(0.35))
|
.background(Color.black.opacity(0.35))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
@@ -219,9 +285,9 @@ struct CIVideoView: View {
|
|||||||
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
|
||||||
.tint(color)
|
.tint(color)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
|
||||||
.background(Color.black.opacity(0.35))
|
.background(Color.black.opacity(0.35))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
@@ -252,17 +318,32 @@ struct CIVideoView: View {
|
|||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: w)
|
.frame(width: w)
|
||||||
loadingIndicator()
|
.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
|
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||||
|
fileStatusIcon()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func loadingIndicator() -> some View {
|
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: maxWidth, height: maxWidth)
|
||||||
|
if file.showStatusIconInSmallView {
|
||||||
|
fileStatusIcon()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func fileStatusIcon() -> some View {
|
||||||
if let file = chatItem.file {
|
if let file = chatItem.file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .sndStored:
|
case .sndStored:
|
||||||
@@ -279,7 +360,22 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case let .sndError(sndFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||||
@@ -289,10 +385,25 @@ struct CIVideoView: View {
|
|||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case let .rcvError(rcvFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +414,7 @@ struct CIVideoView: View {
|
|||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(padding)
|
.padding(smallView ? 0 : padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progressView() -> some View {
|
private func progressView() -> some View {
|
||||||
@@ -311,7 +422,7 @@ struct CIVideoView: View {
|
|||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
.padding(11)
|
.padding(smallView ? 0 : 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||||
@@ -323,7 +434,7 @@ struct CIVideoView: View {
|
|||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.padding([.trailing, .top], 11)
|
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO encrypt: where file size is checked?
|
// TODO encrypt: where file size is checked?
|
||||||
@@ -363,7 +474,8 @@ struct CIVideoView: View {
|
|||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||||
m.stopPreviousRecPlay = url
|
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
|
||||||
|
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
|
||||||
if let player = fullPlayer {
|
if let player = fullPlayer {
|
||||||
player.play()
|
player.play()
|
||||||
var played = false
|
var played = false
|
||||||
@@ -400,10 +512,12 @@ struct CIVideoView: View {
|
|||||||
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let decrypted = urlDecrypted {
|
if let decrypted = urlDecrypted {
|
||||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
if !smallView {
|
||||||
|
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||||
|
}
|
||||||
fullPlayer = AVPlayer(url: decrypted)
|
fullPlayer = AVPlayer(url: decrypted)
|
||||||
}
|
}
|
||||||
decryptionInProgress = true
|
decryptionInProgress = false
|
||||||
completed?()
|
completed?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,30 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIVoiceView: View {
|
struct CIVoiceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
@State var audioPlayer: AudioPlayer? = nil
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
@Binding var playbackTime: TimeInterval?
|
@State var playbackTime: TimeInterval? = nil
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
var smallViewSize: CGFloat?
|
||||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if chatItem.chatDir.sent {
|
if smallViewSize != nil {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
player()
|
||||||
|
playerTime()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
|
playbackSlider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if chatItem.chatDir.sent {
|
||||||
VStack (alignment: .trailing, spacing: 6) {
|
VStack (alignment: .trailing, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
@@ -53,7 +65,13 @@ struct CIVoiceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func player() -> some View {
|
private func player() -> some View {
|
||||||
VoiceMessagePlayer(
|
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
|
||||||
|
voiceMessageSizeBasedOnSquareSize(sz) / 56
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
return VoiceMessagePlayer(
|
||||||
|
chat: chat,
|
||||||
chatItem: chatItem,
|
chatItem: chatItem,
|
||||||
recordingFile: recordingFile,
|
recordingFile: recordingFile,
|
||||||
recordingTime: TimeInterval(duration),
|
recordingTime: TimeInterval(duration),
|
||||||
@@ -62,7 +80,8 @@ struct CIVoiceView: View {
|
|||||||
audioPlayer: $audioPlayer,
|
audioPlayer: $audioPlayer,
|
||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime,
|
playbackTime: $playbackTime,
|
||||||
allowMenu: $allowMenu
|
allowMenu: $allowMenu,
|
||||||
|
sizeMultiplier: sizeMultiplier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +91,7 @@ struct CIVoiceView: View {
|
|||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime
|
playbackTime: $playbackTime
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playbackSlider() -> some View {
|
private func playbackSlider() -> some View {
|
||||||
@@ -89,10 +108,11 @@ struct CIVoiceView: View {
|
|||||||
allowMenu = true
|
allowMenu = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func metaView() -> some View {
|
private func metaView() -> some View {
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +137,9 @@ struct VoiceMessagePlayerTime: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct VoiceMessagePlayer: View {
|
struct VoiceMessagePlayer: View {
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var recordingFile: CIFile?
|
var recordingFile: CIFile?
|
||||||
var recordingTime: TimeInterval
|
var recordingTime: TimeInterval
|
||||||
@@ -128,24 +149,61 @@ struct VoiceMessagePlayer: View {
|
|||||||
@Binding var audioPlayer: AudioPlayer?
|
@Binding var audioPlayer: AudioPlayer?
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
@Binding var playbackState: VoiceMessagePlaybackState
|
||||||
@Binding var playbackTime: TimeInterval?
|
@Binding var playbackTime: TimeInterval?
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
var sizeMultiplier: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let recordingFile = recordingFile {
|
if let recordingFile = recordingFile {
|
||||||
switch recordingFile.fileStatus {
|
switch recordingFile.fileStatus {
|
||||||
case .sndStored: playbackButton()
|
case .sndStored:
|
||||||
case .sndTransfer: playbackButton()
|
if recordingFile.fileProtocol == .local {
|
||||||
|
playbackButton()
|
||||||
|
} else {
|
||||||
|
loadingIcon()
|
||||||
|
}
|
||||||
|
case .sndTransfer: loadingIcon()
|
||||||
case .sndComplete: playbackButton()
|
case .sndComplete: playbackButton()
|
||||||
case .sndCancelled: playbackButton()
|
case .sndCancelled: playbackButton()
|
||||||
case .sndError: playbackButton()
|
case let .sndError(sndFileError):
|
||||||
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
||||||
case .rcvAccepted: loadingIcon()
|
case .rcvAccepted: loadingIcon()
|
||||||
case .rcvTransfer: loadingIcon()
|
case .rcvTransfer: loadingIcon()
|
||||||
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
||||||
case .rcvComplete: playbackButton()
|
case .rcvComplete: playbackButton()
|
||||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case let .rcvError(rcvFileError):
|
||||||
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -153,84 +211,170 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
if audioPlayer == nil {
|
||||||
|
let small = sizeMultiplier != 1
|
||||||
|
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
|
||||||
|
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
|
||||||
|
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
|
||||||
|
}
|
||||||
seek = { to in audioPlayer?.seek(to) }
|
seek = { to in audioPlayer?.seek(to) }
|
||||||
audioPlayer?.onTimer = { playbackTime = $0 }
|
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||||
|
getAppFilePath(recordingSource.filePath)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let chatId = chatModel.chatId
|
||||||
|
let userId = chatModel.currentUser?.userId
|
||||||
|
audioPlayer?.onTimer = {
|
||||||
|
playbackTime = $0
|
||||||
|
notifyStateChange()
|
||||||
|
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||||
|
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
audioPlayer?.onFinishPlayback = {
|
audioPlayer?.onFinishPlayback = {
|
||||||
playbackState = .noPlayback
|
playbackState = .noPlayback
|
||||||
playbackTime = TimeInterval(0)
|
playbackTime = TimeInterval(0)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
|
||||||
|
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
|
||||||
|
stopPlayback()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||||
audioPlayer?.stop()
|
stopPlayback()
|
||||||
playbackState = .noPlayback
|
|
||||||
playbackTime = TimeInterval(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: playbackState) { state in
|
.onChange(of: playbackState) { state in
|
||||||
allowMenu = state == .paused || state == .noPlayback
|
allowMenu = state == .paused || state == .noPlayback
|
||||||
|
// Notify activeContentPreview in ChatPreviewView that playback is finished
|
||||||
|
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||||
|
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
|
||||||
|
chatModel.stopPreviousRecPlay = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: chatModel.chatId) { _ in
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if sizeMultiplier == 1 && chatModel.chatId == nil {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func playbackButton() -> some View {
|
@ViewBuilder private func playbackButton() -> some View {
|
||||||
switch playbackState {
|
if sizeMultiplier != 1 {
|
||||||
case .noPlayback:
|
switch playbackState {
|
||||||
Button {
|
case .noPlayback:
|
||||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
startPlayback(recordingSource)
|
.onTapGesture {
|
||||||
|
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||||
|
startPlayback(recordingSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .playing:
|
||||||
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
playbackState = .paused
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
case .paused:
|
||||||
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
audioPlayer?.play()
|
||||||
|
playbackState = .playing
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch playbackState {
|
||||||
|
case .noPlayback:
|
||||||
|
Button {
|
||||||
|
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||||
|
startPlayback(recordingSource)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
|
}
|
||||||
|
case .playing:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
playbackState = .paused
|
||||||
|
notifyStateChange()
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
|
}
|
||||||
|
case .paused:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.play()
|
||||||
|
playbackState = .playing
|
||||||
|
notifyStateChange()
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
playPauseIcon("play.fill")
|
|
||||||
}
|
|
||||||
case .playing:
|
|
||||||
Button {
|
|
||||||
audioPlayer?.pause()
|
|
||||||
playbackState = .paused
|
|
||||||
} label: {
|
|
||||||
playPauseIcon("pause.fill")
|
|
||||||
}
|
|
||||||
case .paused:
|
|
||||||
Button {
|
|
||||||
audioPlayer?.play()
|
|
||||||
playbackState = .playing
|
|
||||||
} label: {
|
|
||||||
playPauseIcon("play.fill")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
if recordingTime > 0 {
|
if recordingTime > 0 {
|
||||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||||
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
|
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
||||||
Button {
|
Group {
|
||||||
Task {
|
if sizeMultiplier != 1 {
|
||||||
if let user = chatModel.currentUser {
|
playPauseIcon(icon, theme.colors.primary)
|
||||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
.onTapGesture {
|
||||||
|
Task {
|
||||||
|
if let user = chatModel.currentUser {
|
||||||
|
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
if let user = chatModel.currentUser {
|
||||||
|
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon(icon, theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
}
|
||||||
playPauseIcon(icon)
|
}
|
||||||
|
|
||||||
|
func notifyStateChange() {
|
||||||
|
if sizeMultiplier != 1 {
|
||||||
|
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||||
|
} else {
|
||||||
|
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProgressCircle: View {
|
private struct ProgressCircle: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
@@ -238,7 +382,7 @@ struct VoiceMessagePlayer: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
||||||
.stroke(
|
.stroke(
|
||||||
Color.accentColor,
|
theme.colors.primary,
|
||||||
style: StrokeStyle(lineWidth: 3)
|
style: StrokeStyle(lineWidth: 3)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
@@ -246,26 +390,100 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
|
||||||
|
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||||
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
private func loadingIcon() -> some View {
|
private func loadingIcon() -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPlayback(_ recordingSource: CryptoFile) {
|
private func startPlayback(_ recordingSource: CryptoFile) {
|
||||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
|
let audioPath = getAppFilePath(recordingSource.filePath)
|
||||||
|
let chatId = chatModel.chatId
|
||||||
|
let userId = chatModel.currentUser?.userId
|
||||||
|
chatModel.stopPreviousRecPlay = audioPath
|
||||||
audioPlayer = AudioPlayer(
|
audioPlayer = AudioPlayer(
|
||||||
onTimer: { playbackTime = $0 },
|
onTimer: {
|
||||||
|
playbackTime = $0
|
||||||
|
notifyStateChange()
|
||||||
|
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||||
|
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
},
|
||||||
onFinishPlayback: {
|
onFinishPlayback: {
|
||||||
playbackState = .noPlayback
|
playbackState = .noPlayback
|
||||||
playbackTime = TimeInterval(0)
|
playbackTime = TimeInterval(0)
|
||||||
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||||
playbackState = .playing
|
playbackState = .playing
|
||||||
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stopPlayback() {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
playbackState = .noPlayback
|
||||||
|
playbackTime = TimeInterval(0)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
|
||||||
|
let squareToCircleRatio = 0.935
|
||||||
|
return squareSize + squareSize * (1 - squareToCircleRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceItemState {
|
||||||
|
var audioPlayer: AudioPlayer?
|
||||||
|
var playbackState: VoiceMessagePlaybackState
|
||||||
|
var playbackTime: TimeInterval?
|
||||||
|
|
||||||
|
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
|
||||||
|
self.audioPlayer = audioPlayer
|
||||||
|
self.playbackState = playbackState
|
||||||
|
self.playbackTime = playbackTime
|
||||||
|
}
|
||||||
|
|
||||||
|
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
|
||||||
|
"\(chat.id) \(chatItem.id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
|
||||||
|
"\(chatInfo.id) \(chatItem.id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||||
|
let id = id(chatInfo, chatItem)
|
||||||
|
if let item = smallView[id] {
|
||||||
|
item.audioPlayer?.stop()
|
||||||
|
ChatModel.shared.stopPreviousRecPlay = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||||
|
let id = id(chatInfo, chatItem)
|
||||||
|
if let item = chatView[id] {
|
||||||
|
item.audioPlayer?.stop()
|
||||||
|
ChatModel.shared.stopPreviousRecPlay = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var smallView: [String: VoiceItemState] = [:]
|
||||||
|
static var chatView: [String: VoiceItemState] = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIVoiceView_Previews: PreviewProvider {
|
struct CIVoiceView_Previews: PreviewProvider {
|
||||||
@@ -290,15 +508,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
|||||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||||
duration: 30,
|
duration: 30,
|
||||||
audioPlayer: .constant(nil),
|
|
||||||
playbackState: .constant(.playing),
|
|
||||||
playbackTime: .constant(TimeInterval(20)),
|
|
||||||
allowMenu: Binding.constant(true)
|
allowMenu: Binding.constant(true)
|
||||||
)
|
)
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
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), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
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), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
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), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,21 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct DeletedItemView: View {
|
struct DeletedItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .bottom, spacing: 0) {
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
Text(chatItem.content.text)
|
Text(chatItem.content.text)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.italic()
|
.italic()
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||