Compare commits
378 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 |
@@ -5,12 +5,22 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- users
|
||||
tags:
|
||||
- "v*"
|
||||
- "!*-fdroid"
|
||||
- "!*-armv7a"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "apps/ios"
|
||||
- "apps/multiplatform"
|
||||
- "blog"
|
||||
- "docs"
|
||||
- "fastlane"
|
||||
- "images"
|
||||
- "packages"
|
||||
- "website"
|
||||
- "README.md"
|
||||
- "PRIVACY.md"
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
|
||||
@@ -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).
|
||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||
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).
|
||||
|
||||
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
|
||||
- contributing to SimpleX Chat knowledge-base.
|
||||
- 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!
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
|
||||
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
|
||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
|
||||
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||
- USDT:
|
||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
|
||||
- please ask if you want to donate any other coins.
|
||||
|
||||
Thank you,
|
||||
|
||||
@@ -234,6 +233,10 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
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)
|
||||
|
||||
[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.
|
||||
- ✅ Improve sending videos (including encryption of locally stored videos).
|
||||
- ✅ 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 experience for the new users.
|
||||
- 🏗 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.
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
|
||||
@@ -9,35 +9,18 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||
removePasscodesIfReinstalled()
|
||||
prepareForLaunch()
|
||||
return true
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private func trackKeyboard() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillShow(_ notification: Notification) {
|
||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
||||
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillHide(_ notification: Notification) {
|
||||
ChatModel.shared.keyboardHeight = 0
|
||||
}
|
||||
|
||||
@objc func pasteboardChanged() {
|
||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
}
|
||||
@@ -141,6 +124,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareForLaunch() {
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
static func keepScreenOn(_ on: Bool) {
|
||||
UIApplication.shared.isIdleTimerDisabled = on
|
||||
}
|
||||
@@ -148,13 +135,79 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
static var windowStatic: UIWindow?
|
||||
var windowScene: UIWindowScene?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
UITableView.appearance().backgroundColor = .clear
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
self.windowScene = windowScene
|
||||
window = windowScene.keyWindow
|
||||
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
|
||||
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
|
||||
SceneDelegate.windowStatic = windowScene.keyWindow
|
||||
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" : [
|
||||
{
|
||||
"filename" : "40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
"filename" : "any.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"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" : [
|
||||
{
|
||||
"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",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"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 callController = CallController.shared
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
|
||||
var contentAccessAuthenticationExtended: Bool
|
||||
|
||||
@@ -34,7 +36,7 @@ struct ContentView: View {
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
|
||||
private let callTopPadding: CGFloat = 50
|
||||
private let callTopPadding: CGFloat = 40
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||
@@ -51,6 +53,16 @@ struct ContentView: 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 {
|
||||
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.
|
||||
@@ -138,6 +150,17 @@ struct ContentView: View {
|
||||
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 {
|
||||
@@ -184,7 +207,7 @@ struct ContentView: View {
|
||||
CallDuration(call: call)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(height: callTopPadding - 10)
|
||||
.frame(height: callTopPadding)
|
||||
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||
.onTapGesture {
|
||||
chatModel.activeCallViewIsCollapsed = false
|
||||
@@ -224,8 +247,8 @@ struct ContentView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||
.background(
|
||||
Rectangle()
|
||||
.fill(.background)
|
||||
)
|
||||
.fill(theme.colors.background)
|
||||
)
|
||||
}
|
||||
|
||||
private func mainView() -> some View {
|
||||
|
||||
@@ -43,11 +43,103 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
||||
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 {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@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 chatInitialized = false
|
||||
@Published var chatRunning: Bool?
|
||||
@@ -59,16 +151,15 @@ final class ChatModel: ObservableObject {
|
||||
@Published var contentViewAccessAuthenticated: Bool = false
|
||||
@Published var laRequest: LocalAuthRequest?
|
||||
// list of chat "previews"
|
||||
@Published var chats: [Chat] = []
|
||||
@Published private(set) var chats: [Chat] = []
|
||||
@Published var deletedChats: Set<String> = []
|
||||
// map of connections network statuses, key is agent connection id
|
||||
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var reversedChatItems: [ChatItem] = []
|
||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||
@Published var chatToTop: String?
|
||||
@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
|
||||
@Published var showingTerminal = false
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@@ -100,8 +191,6 @@ final class ChatModel: ObservableObject {
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
// tracks keyboard height via subscription in AppDelegate
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||
|
||||
@@ -111,6 +200,8 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
static let shared = ChatModel()
|
||||
|
||||
let im = ItemsModel.shared
|
||||
|
||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||
|
||||
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? {
|
||||
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? {
|
||||
chats.firstIndex(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func addChat(_ chat: Chat, at position: Int = 0) {
|
||||
withAnimation {
|
||||
chats.insert(chat, at: position)
|
||||
func addChat(_ chat: Chat) {
|
||||
if chatId == nil {
|
||||
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) {
|
||||
@@ -245,26 +365,10 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateChats(with newChats: [ChatData]) {
|
||||
for i in 0..<newChats.count {
|
||||
let c = newChats[i]
|
||||
if let j = getChatIndex(c.id) {
|
||||
let chat = chats[j]
|
||||
chat.chatInfo = c.chatInfo
|
||||
chat.chatItems = c.chatItems
|
||||
chat.chatStats = c.chatStats
|
||||
if i != j {
|
||||
if chatId != c.chatInfo.id {
|
||||
popChat_(j, to: i)
|
||||
} else if i == 0 {
|
||||
chatToTop = c.chatInfo.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addChat(Chat(c), at: i)
|
||||
}
|
||||
}
|
||||
func updateChats(_ newChats: [ChatData]) {
|
||||
chats = newChats.map { Chat($0) }
|
||||
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
||||
popChatCollector.clear()
|
||||
}
|
||||
|
||||
// func addGroup(_ group: SimpleXChat.Group) {
|
||||
@@ -272,6 +376,12 @@ final class ChatModel: ObservableObject {
|
||||
// }
|
||||
|
||||
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
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatItems = switch cInfo {
|
||||
@@ -289,18 +399,9 @@ final class ChatModel: ObservableObject {
|
||||
[cItem]
|
||||
}
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
||||
increaseUnreadCounter(user: currentUser!)
|
||||
}
|
||||
if i > 0 {
|
||||
if chatId == nil {
|
||||
withAnimation { popChat_(i) }
|
||||
} else if chatId == cInfo.id {
|
||||
chatToTop = cInfo.id
|
||||
} else {
|
||||
popChat_(i)
|
||||
}
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
|
||||
}
|
||||
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
|
||||
} else {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
}
|
||||
@@ -315,7 +416,7 @@ final class ChatModel: ObservableObject {
|
||||
var res: Bool
|
||||
if let chat = getChat(cInfo.id) {
|
||||
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]
|
||||
}
|
||||
} else {
|
||||
@@ -326,24 +427,26 @@ final class ChatModel: ObservableObject {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
res = true
|
||||
}
|
||||
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
|
||||
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||
}
|
||||
// update current chat
|
||||
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
||||
}
|
||||
|
||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
ChatItemDummyModel.shared.sendUpdate()
|
||||
return false
|
||||
} else {
|
||||
withAnimation(itemAnimation()) {
|
||||
var ci = cItem
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
}
|
||||
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
var ci = cItem
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
}
|
||||
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
im.itemAdded = true
|
||||
ChatItemDummyModel.shared.sendUpdate()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -357,7 +460,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
withConditionalAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
} else if let status = status {
|
||||
@@ -366,17 +469,17 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||
reversedChatItems[i] = cItem
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
im.reversedChatItems[i] = cItem
|
||||
im.reversedChatItems[i].viewTimestamp = .now
|
||||
}
|
||||
|
||||
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) {
|
||||
if cItem.isRcvNew {
|
||||
decreaseUnreadCounter(cInfo)
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||
}
|
||||
// update previews
|
||||
if let chat = getChat(cInfo.id) {
|
||||
@@ -388,23 +491,24 @@ final class ChatModel: ObservableObject {
|
||||
if chatId == cInfo.id {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
_ = 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? {
|
||||
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 {
|
||||
while i < reversedChatItems.count - 1 {
|
||||
while i < im.reversedChatItems.count - 1 {
|
||||
i += 1
|
||||
if let res = map(reversedChatItems[i]) { return res }
|
||||
if let res = map(im.reversedChatItems[i]) { return res }
|
||||
}
|
||||
} else {
|
||||
while i > 0 {
|
||||
i -= 1
|
||||
if let res = map(reversedChatItems[i]) { return res }
|
||||
if let res = map(im.reversedChatItems[i]) { return res }
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||
withAnimation {
|
||||
reversedChatItems.insert(cItem, at: 0)
|
||||
im.reversedChatItems.insert(cItem, at: 0)
|
||||
im.itemAdded = true
|
||||
}
|
||||
return cItem
|
||||
}
|
||||
@@ -433,15 +548,15 @@ final class ChatModel: ObservableObject {
|
||||
func removeLiveDummy(animated: Bool = true) {
|
||||
if hasLiveDummy {
|
||||
if animated {
|
||||
withAnimation { _ = reversedChatItems.removeFirst() }
|
||||
withAnimation { _ = im.reversedChatItems.removeFirst() }
|
||||
} else {
|
||||
_ = reversedChatItems.removeFirst()
|
||||
_ = im.reversedChatItems.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasLiveDummy: Bool {
|
||||
reversedChatItems.first?.isLiveDummy == true
|
||||
im.reversedChatItems.first?.isLiveDummy == true
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
@@ -458,7 +573,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||
var j = i
|
||||
while j < reversedChatItems.count {
|
||||
while j < im.reversedChatItems.count {
|
||||
markChatItemRead_(j)
|
||||
j += 1
|
||||
}
|
||||
@@ -472,7 +587,7 @@ final class ChatModel: ObservableObject {
|
||||
var unreadBelow = 0
|
||||
var j = i - 1
|
||||
while j >= 0 {
|
||||
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
|
||||
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
|
||||
unreadBelow += 1
|
||||
}
|
||||
j -= 1
|
||||
@@ -507,51 +622,146 @@ final class ChatModel: ObservableObject {
|
||||
// clear current chat
|
||||
if chatId == cInfo.id {
|
||||
chatItemStatuses = [:]
|
||||
reversedChatItems = []
|
||||
im.reversedChatItems = []
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markChatItemRead_(i)
|
||||
}
|
||||
}
|
||||
|
||||
private func markChatItemRead_(_ i: Int) {
|
||||
let meta = reversedChatItems[i].meta
|
||||
if case .rcvNew = meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
||||
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
if chatId == cInfo.id,
|
||||
let itemIndex = getChatItemIndex(cItem),
|
||||
im.reversedChatItems[itemIndex].isRcvNew {
|
||||
await MainActor.run {
|
||||
withTransaction(Transaction()) {
|
||||
// update current chat
|
||||
markChatItemRead_(itemIndex)
|
||||
// update preview
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
decreaseUnreadCounter(user: currentUser!)
|
||||
private let unreadCollector = UnreadCollector()
|
||||
|
||||
class UnreadCollector {
|
||||
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) {
|
||||
changeUnreadCounter(user: user, by: 1)
|
||||
NtfManager.shared.incNtfBadgeCount()
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||
changeUnreadCounter(user: user, by: -by)
|
||||
NtfManager.shared.decNtfBadgeCount(by: by)
|
||||
}
|
||||
|
||||
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
||||
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
||||
users[i].unreadCount += by
|
||||
}
|
||||
NtfManager.shared.changeNtfBadgeCount(by: by)
|
||||
}
|
||||
|
||||
func totalUnreadCountForAllUsers() -> Int {
|
||||
@@ -565,8 +775,8 @@ final class ChatModel: ObservableObject {
|
||||
var ns: [String] = []
|
||||
if let ciCategory = chatItem.mergeCategory,
|
||||
var i = getChatItemIndex(chatItem) {
|
||||
while i < reversedChatItems.count {
|
||||
let ci = reversedChatItems[i]
|
||||
while i < im.reversedChatItems.count {
|
||||
let ci = im.reversedChatItems[i]
|
||||
if ci.mergeCategory != ciCategory { break }
|
||||
if let m = ci.memberConnected {
|
||||
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)
|
||||
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
||||
if let i = getChatItemIndex(ci) {
|
||||
(i, i > 0 ? reversedChatItems[i - 1] : nil)
|
||||
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
|
||||
} else {
|
||||
(nil, nil)
|
||||
}
|
||||
@@ -591,10 +801,10 @@ final class ChatModel: ObservableObject {
|
||||
// and the previous visible item with another merge category
|
||||
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
||||
guard var i = ciIndex else { return (nil, nil) }
|
||||
let fst = reversedChatItems.count - 1
|
||||
let fst = im.reversedChatItems.count - 1
|
||||
while i < fst {
|
||||
i = i + 1
|
||||
let ci = reversedChatItems[i]
|
||||
let ci = im.reversedChatItems[i]
|
||||
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
||||
return (i - 1, ci)
|
||||
}
|
||||
@@ -607,7 +817,7 @@ final class ChatModel: ObservableObject {
|
||||
var prevMember: GroupMember? = nil
|
||||
var memberIds: Set<Int64> = []
|
||||
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 }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
}
|
||||
@@ -617,6 +827,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
// no animation here, for it not to look like it just moved when leaving the chat
|
||||
popChat_(i)
|
||||
}
|
||||
}
|
||||
@@ -651,14 +862,17 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// update current chat
|
||||
if chatId == groupInfo.id {
|
||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
||||
if let i = groupMembersIndexes[member.groupMemberId] {
|
||||
withAnimation(.default) {
|
||||
self.groupMembers[i].wrapped = member
|
||||
self.groupMembers[i].created = Date.now
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { groupMembers.append(GMember(member)) }
|
||||
withAnimation {
|
||||
groupMembers.append(GMember(member))
|
||||
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -674,43 +888,6 @@ final class ChatModel: ObservableObject {
|
||||
_ = 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 {
|
||||
@@ -723,16 +900,12 @@ struct NTFContactRequest {
|
||||
var chatId: String
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts {
|
||||
var totalBelow: Int
|
||||
var unreadBelow: Int
|
||||
}
|
||||
|
||||
final class Chat: ObservableObject, Identifiable {
|
||||
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
@Published var chatStats: ChatStats
|
||||
var created = Date.now
|
||||
fileprivate var popTs: Date?
|
||||
|
||||
init(_ cData: ChatData) {
|
||||
self.chatInfo = cData.chatInfo
|
||||
@@ -779,24 +952,6 @@ final class Chat: ObservableObject, Identifiable {
|
||||
|
||||
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: [])
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
||||
}
|
||||
} else {
|
||||
chatModel.chatId = content.targetContentIdentifier
|
||||
if let chatId = content.targetContentIdentifier {
|
||||
ItemsModel.shared.loadOpenChat(chatId)
|
||||
}
|
||||
}
|
||||
handler()
|
||||
}
|
||||
@@ -238,12 +240,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
ntfBadgeCountGroupDefault.set(count)
|
||||
}
|
||||
|
||||
func decNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
|
||||
}
|
||||
|
||||
func incNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
|
||||
func changeNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
|
||||
}
|
||||
|
||||
private func addNotification(_ content: UNMutableNotificationContent) {
|
||||
|
||||
@@ -92,25 +92,29 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
||||
return r
|
||||
}
|
||||
|
||||
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse {
|
||||
if log {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
}
|
||||
let start = Date.now
|
||||
let resp = bgTask
|
||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
||||
: sendSimpleXCmd(cmd, ctrl)
|
||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||
if case let .response(_, json) = resp {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||
}
|
||||
Task {
|
||||
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
||||
if log {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||
if case let .response(_, json) = resp {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||
}
|
||||
Task {
|
||||
await TerminalItems.shared.addCommand(start, cmd.obfuscated, 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
|
||||
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 {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
|
||||
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
@@ -213,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
|
||||
}
|
||||
|
||||
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 {
|
||||
case .chatStarted: return true
|
||||
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 {
|
||||
let r = await chatSendCmd(.apiStopChat)
|
||||
switch r {
|
||||
@@ -242,14 +255,8 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||
logger.error("apiSuspendChat error: \(String(describing: r))")
|
||||
}
|
||||
|
||||
func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
|
||||
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||
let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
@@ -272,8 +279,10 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiExportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
||||
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||
let r = await chatSendCmd(.apiExportArchive(config: config))
|
||||
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||
@@ -309,8 +318,10 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
|
||||
let loadItemsPerPage = 50
|
||||
|
||||
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) }
|
||||
throw r
|
||||
}
|
||||
@@ -321,15 +332,20 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "") {
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
let im = ItemsModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
m.reversedChatItems = []
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
m.reversedChatItems = chat.chatItems.reversed()
|
||||
if clearItems {
|
||||
await MainActor.run { im.reversedChatItems = [] }
|
||||
}
|
||||
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 {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
@@ -341,17 +357,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
|
||||
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
|
||||
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
|
||||
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
|
||||
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? {
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
|
||||
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
|
||||
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
|
||||
return await processSendMessageCmd(toChatType: type, cmd: cmd)
|
||||
}
|
||||
|
||||
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
|
||||
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? {
|
||||
let chatModel = ChatModel.shared
|
||||
let r: ChatResponse
|
||||
if toChatType == .direct {
|
||||
@@ -364,10 +380,13 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
|
||||
}
|
||||
})
|
||||
r = await chatSendCmd(cmd, bgTask: false)
|
||||
if case let .newChatItem(_, aChatItem) = r {
|
||||
cItem = aChatItem.chatItem
|
||||
chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
|
||||
return cItem
|
||||
if case let .newChatItems(_, aChatItems) = r {
|
||||
let cItems = aChatItems.map { $0.chatItem }
|
||||
if let cItemLast = cItems.last {
|
||||
cItem = cItemLast
|
||||
chatModel.messageDelivery[cItemLast.id] = endTask
|
||||
}
|
||||
return cItems
|
||||
}
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
AlertManager.shared.showAlert(networkErrorAlert)
|
||||
@@ -378,18 +397,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
|
||||
return nil
|
||||
} else {
|
||||
r = await chatSendCmd(cmd, bgDelay: msgDelay)
|
||||
if case let .newChatItem(_, aChatItem) = r {
|
||||
return aChatItem.chatItem
|
||||
if case let .newChatItems(_, aChatItems) = r {
|
||||
return aChatItems.map { $0.chatItem }
|
||||
}
|
||||
sendMessageErrorAlert(r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
|
||||
let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
|
||||
if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
|
||||
createChatItemErrorAlert(r)
|
||||
func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
|
||||
let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
|
||||
if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
|
||||
createChatItemsErrorAlert(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -397,15 +416,15 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("send message error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error sending message",
|
||||
message: "Error: \(String(describing: r))"
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
|
||||
private func createChatItemErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("apiCreateChatItem error: \(String(describing: r))")
|
||||
private func createChatItemsErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("apiCreateChatItems error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
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
|
||||
}
|
||||
|
||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
|
||||
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
|
||||
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
|
||||
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -541,6 +560,11 @@ func reconnectAllServers() async throws {
|
||||
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 {
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
@@ -561,6 +585,18 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Gro
|
||||
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 {
|
||||
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
|
||||
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
|
||||
@@ -640,6 +676,13 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
|
||||
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
|
||||
|
||||
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
|
||||
let userId = try currentUserId("apiConnectPlan")
|
||||
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
|
||||
@@ -672,7 +715,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
||||
return ((.contact, connection), nil)
|
||||
case let .contactAlreadyExists(_, contact):
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
}
|
||||
let alert = contactAlreadyExistsAlert(contact)
|
||||
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."
|
||||
)
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
|
||||
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
|
||||
let alert = mkAlert(
|
||||
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."
|
||||
@@ -715,7 +758,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
} else {
|
||||
return mkAlert(
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(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 .contactConnection = type, case .contactConnectionDeleted = r { return }
|
||||
if case .group = type, case .groupDeletedUser = r { return }
|
||||
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 {
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
|
||||
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
|
||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
|
||||
} catch let error {
|
||||
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
||||
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 {
|
||||
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
||||
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
|
||||
@@ -819,6 +911,21 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe
|
||||
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 {
|
||||
let userId = try currentUserId("apiCreateUserAddress")
|
||||
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
|
||||
@@ -866,7 +973,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
||||
let am = AlertManager.shared
|
||||
|
||||
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
||||
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
|
||||
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
|
||||
am.showAlertMsg(
|
||||
title: "Connection error (AUTH)",
|
||||
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))")
|
||||
am.showAlertMsg(
|
||||
title: "Error accepting contact request",
|
||||
message: "Error: \(String(describing: r))"
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@@ -903,7 +1010,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
|
||||
return (fileTransferMeta, nil)
|
||||
} else {
|
||||
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)
|
||||
} else {
|
||||
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 {
|
||||
am.showAlertMsg(
|
||||
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? {
|
||||
switch r {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
return mkAlert(
|
||||
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:
|
||||
if let alert = getNetworkErrorAlert(r) {
|
||||
return mkAlert(title: alert.title, message: alert.message)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1079,7 +1177,17 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
||||
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
||||
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))
|
||||
}
|
||||
|
||||
func apiGetCallInvitations() throws -> [RcvCallInvitation] {
|
||||
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
|
||||
let r = chatSendCmdSync(.apiGetCallInvitations)
|
||||
if case let .callInvitations(invs) = r { return invs }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
|
||||
let r = await chatSendCmd(.apiGetCallInvitations)
|
||||
if case let .callInvitations(invs) = r { return invs }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
|
||||
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
|
||||
@@ -1175,7 +1289,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
await ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
} catch {
|
||||
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))
|
||||
switch r {
|
||||
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
|
||||
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 }
|
||||
return ChatModel.shared.chats
|
||||
.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() }
|
||||
}
|
||||
|
||||
@@ -1322,6 +1436,26 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
||||
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 {
|
||||
if let userId = ChatModel.shared.currentUser?.userId {
|
||||
return userId
|
||||
@@ -1341,8 +1475,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
|
||||
if encryptionStartedDefault.get() {
|
||||
encryptionStartedDefault.set(false)
|
||||
}
|
||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path)
|
||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
@@ -1394,15 +1527,16 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
logger.debug("startChat")
|
||||
let m = ChatModel.shared
|
||||
try setNetworkConfig(getNetCfg())
|
||||
let justStarted = try apiStartChat()
|
||||
let chatRunning = try apiCheckChatRunning()
|
||||
m.users = try listUsers()
|
||||
if justStarted {
|
||||
if !chatRunning {
|
||||
try getUserChatData()
|
||||
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
||||
if (refreshInvitations) {
|
||||
try refreshCallInvitations()
|
||||
Task { try await refreshCallInvitations() }
|
||||
}
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
_ = try apiStartChat()
|
||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||
// when it is called before startChat
|
||||
if let token = m.deviceToken {
|
||||
@@ -1427,8 +1561,7 @@ func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
|
||||
logger.debug("startChatWithTemporaryDatabase")
|
||||
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
|
||||
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
|
||||
try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
|
||||
try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
|
||||
try apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl)
|
||||
_ = try apiStartChat(ctrl: ctrl)
|
||||
return migrationActiveUser
|
||||
}
|
||||
@@ -1474,7 +1607,7 @@ func getUserChatData() throws {
|
||||
m.userAddress = try apiGetUserAddress()
|
||||
m.chatItemTTL = try getChatItemTTL()
|
||||
let chats = try apiGetChats()
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
m.updateChats(chats)
|
||||
}
|
||||
|
||||
private func getUserChatDataAsync() async throws {
|
||||
@@ -1486,12 +1619,12 @@ private func getUserChatDataAsync() async throws {
|
||||
await MainActor.run {
|
||||
m.userAddress = userAddress
|
||||
m.chatItemTTL = chatItemTTL
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
m.updateChats(chats)
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
m.userAddress = nil
|
||||
m.chats = []
|
||||
m.updateChats([])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1541,6 +1674,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
await TerminalItems.shared.add(.resp(.now, res))
|
||||
}
|
||||
let m = ChatModel.shared
|
||||
let n = NetworkModel.shared
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .contactDeletedByContact(user, contact):
|
||||
@@ -1563,7 +1697,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
NtfManager.shared.notifyContactConnected(user, contact)
|
||||
}
|
||||
await MainActor.run {
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
n.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
case let .contactConnecting(user, contact):
|
||||
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):
|
||||
if active(user) {
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
@@ -1607,7 +1754,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) && m.hasChat(mergedContact.id) {
|
||||
await MainActor.run {
|
||||
if m.chatId == mergedContact.id {
|
||||
m.chatId = intoContact.id
|
||||
ItemsModel.shared.loadOpenChat(mergedContact.id)
|
||||
}
|
||||
m.removeChat(mergedContact.id)
|
||||
}
|
||||
@@ -1615,46 +1762,48 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .networkStatus(status, connections):
|
||||
// dispatch queue to synchronize access
|
||||
networkStatusesLock.sync {
|
||||
var ns = m.networkStatuses
|
||||
var ns = n.networkStatuses
|
||||
// slow loop is on the background thread
|
||||
for cId in connections {
|
||||
ns[cId] = status
|
||||
}
|
||||
// fast model update is on the main thread
|
||||
DispatchQueue.main.sync {
|
||||
m.networkStatuses = ns
|
||||
n.networkStatuses = ns
|
||||
}
|
||||
}
|
||||
case let .networkStatuses(_, statuses): ()
|
||||
// dispatch queue to synchronize access
|
||||
networkStatusesLock.sync {
|
||||
var ns = m.networkStatuses
|
||||
var ns = n.networkStatuses
|
||||
// slow loop is on the background thread
|
||||
for s in statuses {
|
||||
ns[s.agentConnId] = s.networkStatus
|
||||
}
|
||||
// fast model update is on the main thread
|
||||
DispatchQueue.main.sync {
|
||||
m.networkStatuses = ns
|
||||
n.networkStatuses = ns
|
||||
}
|
||||
}
|
||||
case let .newChatItem(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
await MainActor.run {
|
||||
if active(user) {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||
m.increaseUnreadCounter(user: user)
|
||||
case let .newChatItems(user, chatItems):
|
||||
for chatItem in chatItems {
|
||||
let cInfo = chatItem.chatInfo
|
||||
let cItem = chatItem.chatItem
|
||||
await MainActor.run {
|
||||
if active(user) {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||
m.increaseUnreadCounter(user: user)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId, auto: true)
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
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):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
@@ -1664,10 +1813,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
if let endTask = m.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
case .sndNew: ()
|
||||
case .sndSent: endTask()
|
||||
case .sndRcvd: endTask()
|
||||
case .sndErrorAuth: endTask()
|
||||
case .sndError: endTask()
|
||||
default: ()
|
||||
case .sndWarning: endTask()
|
||||
case .rcvNew: ()
|
||||
case .rcvRead: ()
|
||||
case .invalid: ()
|
||||
}
|
||||
}
|
||||
case let .chatItemUpdated(user, aChatItem):
|
||||
@@ -1678,21 +1832,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||
}
|
||||
}
|
||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
||||
case let .chatItemsDeleted(user, items, _):
|
||||
if !active(user) {
|
||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
||||
await MainActor.run {
|
||||
m.decreaseUnreadCounter(user: user)
|
||||
for item in items {
|
||||
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
|
||||
await MainActor.run {
|
||||
m.decreaseUnreadCounter(user: user)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if let toChatItem = toChatItem {
|
||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||
} else {
|
||||
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
|
||||
for item in items {
|
||||
if let toChatItem = item.toChatItem {
|
||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||
} else {
|
||||
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
||||
@@ -1772,7 +1930,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
if let contact = memberContact {
|
||||
await MainActor.run {
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
n.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
}
|
||||
case let .groupUpdated(user, toGroup):
|
||||
@@ -1814,11 +1972,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
}
|
||||
case let .rcvFileError(user, aChatItem, _):
|
||||
case let .rcvFileError(user, aChatItem, _, _):
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
}
|
||||
case let .rcvFileWarning(user, aChatItem, _, _):
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
}
|
||||
case let .sndFileStart(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileComplete(user, aChatItem, _):
|
||||
@@ -1835,11 +1997,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileError(user, aChatItem, _):
|
||||
case let .sndFileError(user, aChatItem, _, _):
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
}
|
||||
case let .sndFileWarning(user, aChatItem, _, _):
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
}
|
||||
case let .callInvitation(invitation):
|
||||
await MainActor.run {
|
||||
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 ?? "")
|
||||
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,
|
||||
// e.g. when user did not grant permission to access local network yet.
|
||||
if let sess = m.remoteCtrlSession {
|
||||
await MainActor.run {
|
||||
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 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
@@ -1971,12 +2155,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
|
||||
func switchToLocalSession() {
|
||||
let m = ChatModel.shared
|
||||
let n = NetworkModel.shared
|
||||
m.remoteCtrlSession = nil
|
||||
do {
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
||||
m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||
} catch let 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 callInvitations = try justRefreshCallInvitations()
|
||||
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||
m.ntfCallInvitationAction = nil
|
||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||
activateCall(invitation)
|
||||
let callInvitations = try await apiGetCallInvitations()
|
||||
await MainActor.run {
|
||||
m.callInvitations = callsByChat(callInvitations)
|
||||
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||
m.ntfCallInvitationAction = nil
|
||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||
activateCall(invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
|
||||
let m = ChatModel.shared
|
||||
let callInvitations = try apiGetCallInvitations()
|
||||
m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
|
||||
return callInvitations
|
||||
func justRefreshCallInvitations() async throws {
|
||||
let callInvitations = try apiGetCallInvitationsSync()
|
||||
await MainActor.run {
|
||||
ChatModel.shared.callInvitations = callsByChat(callInvitations)
|
||||
}
|
||||
}
|
||||
|
||||
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
|
||||
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
|
||||
result, inv in result[inv.contact.id] = inv
|
||||
}
|
||||
}
|
||||
|
||||
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||
if !callInvitation.user.showNotifications { return }
|
||||
let m = ChatModel.shared
|
||||
logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
|
||||
if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
|
||||
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
|
||||
m.callInvitations[callInvitation.contact.id]?.callUUID = nil
|
||||
}
|
||||
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
||||
} 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() {
|
||||
suspendLockQueue.sync {
|
||||
_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
|
||||
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||
.environmentObject(chatModel)
|
||||
.environmentObject(AppTheme.shared)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
@@ -57,6 +58,7 @@ struct SimpleXApp: App {
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
AppSheetState.shared.scenePhaseActive = phase == .active
|
||||
switch (phase) {
|
||||
case .background:
|
||||
// --- authentication
|
||||
@@ -81,9 +83,11 @@ struct SimpleXApp: App {
|
||||
if appState != .stopped {
|
||||
startChatAndActivate {
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
updateCallInvitations()
|
||||
Task {
|
||||
await updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
await updateCallInvitations()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,16 +132,16 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChats() {
|
||||
private func updateChats() async {
|
||||
do {
|
||||
let chats = try apiGetChats()
|
||||
chatModel.updateChats(with: chats)
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run { chatModel.updateChats(chats) }
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id) {
|
||||
loadChat(chat: chat)
|
||||
Task { await loadChat(chat: chat, clearItems: false) }
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
chatModel.ntfContactRequest = nil
|
||||
await MainActor.run { chatModel.ntfContactRequest = nil }
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
||||
}
|
||||
@@ -147,9 +151,9 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCallInvitations() {
|
||||
private func updateCallInvitations() async {
|
||||
do {
|
||||
try refreshCallInvitations()
|
||||
try await refreshCallInvitations()
|
||||
} catch let 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:
|
||||
closeCallView(client)
|
||||
call.callState = .ended
|
||||
if let uuid = call.callkitUUID {
|
||||
if let uuid = call.callUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
}
|
||||
case .ok:
|
||||
@@ -382,7 +382,7 @@ struct ActiveCallOverlay: View {
|
||||
private func endCallButton() -> some View {
|
||||
let cc = CallController.shared
|
||||
return callButton("phone.down.fill", width: 60, height: 60) {
|
||||
if let uuid = call.callkitUUID {
|
||||
if let uuid = call.callUUID {
|
||||
cc.endCall(callUUID: uuid)
|
||||
} else {
|
||||
cc.endCall(call: call) {}
|
||||
@@ -462,9 +462,9 @@ struct ActiveCallOverlay: View {
|
||||
struct ActiveCallOverlay_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// MPVolumeView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav on 24.04.2024.
|
||||
// Created by Avently on 24.04.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
logger.debug("CallController.provider CXStartCallAction")
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID) {
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
action.fulfill()
|
||||
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
||||
} else {
|
||||
@@ -61,12 +61,30 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
logger.debug("CallController.provider CXAnswerCallAction")
|
||||
if callManager.answerIncomingCall(callUUID: action.callUUID) {
|
||||
// WebRTC call should be in connected state to fulfill.
|
||||
// Otherwise no audio and mic working on lockscreen
|
||||
fulfillOnConnect = action
|
||||
} else {
|
||||
action.fail()
|
||||
Task {
|
||||
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
|
||||
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
|
||||
if !chatIsReady {
|
||||
action.fail()
|
||||
return
|
||||
}
|
||||
if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
|
||||
try? await justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
}
|
||||
await MainActor.run {
|
||||
logger.debug("CallController.provider will answer on call")
|
||||
|
||||
if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
logger.debug("CallController.provider answered on call")
|
||||
// WebRTC call should be in connected state to fulfill.
|
||||
// Otherwise no audio and mic working on lockscreen
|
||||
fulfillOnConnect = action
|
||||
} else {
|
||||
logger.debug("CallController.provider will fail the call")
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +93,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
// Should be nil here if connection was in connected state
|
||||
fulfillOnConnect?.fail()
|
||||
fulfillOnConnect = nil
|
||||
callManager.endCall(callUUID: action.callUUID) { ok in
|
||||
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
|
||||
if ok {
|
||||
action.fulfill()
|
||||
} else {
|
||||
@@ -86,7 +104,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
|
||||
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()
|
||||
} else {
|
||||
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:)
|
||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
logger.debug("CallController: initializing chat")
|
||||
do {
|
||||
try initializeChat(start: true, refreshInvitations: false)
|
||||
} catch let error {
|
||||
logger.error("CallController: initializing chat error: \(error)")
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.debug("CallController: initialized chat")
|
||||
startChatForCall()
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
// There are no invitations in the model, as it was processed by NSE
|
||||
_ = try? justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||
// Extract the call information from the push notification payload
|
||||
let m = ChatModel.shared
|
||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||
let invitation = m.callInvitations[contactId] {
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
if let uuid = invitation.callkitUUID {
|
||||
let displayName = payload.dictionaryPayload["displayName"] as? String,
|
||||
let callUUID = payload.dictionaryPayload["callUUID"] as? String,
|
||||
let uuid = UUID(uuidString: callUUID),
|
||||
let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
|
||||
let mediaStr = payload.dictionaryPayload["media"] as? String,
|
||||
let media = CallMediaType(rawValue: mediaStr) {
|
||||
let update = self.cxCallUpdate(contactId, displayName, media)
|
||||
let callTs = Date(timeIntervalSince1970: callTsInterval)
|
||||
if callTs.timeIntervalSinceNow >= -180 {
|
||||
logger.debug("CallController: report pushkit call via CallKit")
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||
if error != nil {
|
||||
m.callInvitations.removeValue(forKey: contactId)
|
||||
@@ -205,11 +223,31 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
completion()
|
||||
}
|
||||
} else {
|
||||
logger.debug("CallController will expire call 1")
|
||||
self.reportExpiredCall(update: update, completion)
|
||||
}
|
||||
} else {
|
||||
logger.debug("CallController will expire call 2")
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
}
|
||||
|
||||
//DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
logger.debug("CallController: initializing chat")
|
||||
do {
|
||||
try initializeChat(start: true, refreshInvitations: false)
|
||||
} catch let error {
|
||||
logger.error("CallController: initializing chat error: \(error)")
|
||||
if let call = ChatModel.shared.activeCall {
|
||||
self.endCall(call: call, completed: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.debug("CallController: initialized chat")
|
||||
startChatForCall()
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
}
|
||||
|
||||
// This function fulfils the requirement to always report a call when PushKit notification is received,
|
||||
@@ -239,8 +277,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
|
||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
|
||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
@@ -261,6 +299,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
return update
|
||||
}
|
||||
|
||||
private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
|
||||
let update = CXCallUpdate()
|
||||
update.remoteHandle = CXHandle(type: .generic, value: contactId)
|
||||
update.hasVideo = media == .video
|
||||
update.localizedCallerName = displayName
|
||||
return update
|
||||
}
|
||||
|
||||
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting incoming call connected")
|
||||
if CallController.useCallKit() {
|
||||
@@ -272,14 +318,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting outgoing call connected")
|
||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||
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)
|
||||
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
||||
activeCallInvitation = nil
|
||||
@@ -288,14 +334,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func reportCallRemoteEnded(call: Call) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
||||
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() {
|
||||
let handle = CXHandle(type: .generic, value: contact.id)
|
||||
let action = CXStartCallAction(call: uuid, handle: handle)
|
||||
@@ -307,8 +356,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
update.localizedCallerName = contact.displayName
|
||||
self.provider.reportCall(with: uuid, updated: update)
|
||||
}
|
||||
} else if callManager.startOutgoingCall(callUUID: uuid) {
|
||||
if callManager.startOutgoingCall(callUUID: uuid) {
|
||||
} else if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||
if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||
logger.debug("CallController.startCall: call started")
|
||||
} else {
|
||||
logger.error("CallController.startCall: no active call")
|
||||
@@ -318,8 +367,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
|
||||
func answerCall(invitation: RcvCallInvitation) {
|
||||
logger.debug("CallController: answering a call")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
|
||||
requestTransaction(with: CXAnswerCallAction(call: callUUID))
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
requestTransaction(with: CXAnswerCallAction(call: uuid))
|
||||
} else {
|
||||
callManager.answerIncomingCall(invitation: invitation)
|
||||
}
|
||||
@@ -328,10 +377,13 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
func endCall(callUUID: UUID) {
|
||||
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
|
||||
func endCall(callUUID: String) {
|
||||
let uuid = UUID(uuidString: callUUID)
|
||||
logger.debug("CallController: ending the call with UUID \(callUUID)")
|
||||
if CallController.useCallKit() {
|
||||
requestTransaction(with: CXEndCallAction(call: callUUID))
|
||||
if let uuid {
|
||||
requestTransaction(with: CXEndCallAction(call: uuid))
|
||||
}
|
||||
} else {
|
||||
callManager.endCall(callUUID: callUUID) { ok in
|
||||
if ok {
|
||||
|
||||
@@ -10,17 +10,17 @@ import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
class CallManager {
|
||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
|
||||
let uuid = UUID()
|
||||
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
|
||||
let uuid = UUID().uuidString.lowercased()
|
||||
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
||||
call.speakerEnabled = media == .video
|
||||
ChatModel.shared.activeCall = call
|
||||
return uuid
|
||||
}
|
||||
|
||||
func startOutgoingCall(callUUID: UUID) -> Bool {
|
||||
func startOutgoingCall(callUUID: String) -> Bool {
|
||||
let m = ChatModel.shared
|
||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||
if let call = m.activeCall, call.callUUID == callUUID {
|
||||
m.showCallView = true
|
||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||
return true
|
||||
@@ -28,7 +28,7 @@ class CallManager {
|
||||
return false
|
||||
}
|
||||
|
||||
func answerIncomingCall(callUUID: UUID) -> Bool {
|
||||
func answerIncomingCall(callUUID: String) -> Bool {
|
||||
if let invitation = getCallInvitation(callUUID) {
|
||||
answerIncomingCall(invitation: invitation)
|
||||
return true
|
||||
@@ -42,7 +42,7 @@ class CallManager {
|
||||
let call = Call(
|
||||
direction: .incoming,
|
||||
contact: invitation.contact,
|
||||
callkitUUID: invitation.callkitUUID,
|
||||
callUUID: invitation.callUUID,
|
||||
callState: .invitationAccepted,
|
||||
localMedia: invitation.callType.media,
|
||||
sharedKey: invitation.sharedKey
|
||||
@@ -68,8 +68,8 @@ class CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: String) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||
let m = ChatModel.shared
|
||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||
return true
|
||||
@@ -77,8 +77,8 @@ class CallManager {
|
||||
return false
|
||||
}
|
||||
|
||||
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
|
||||
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||
endCall(call: call) { completed(true) }
|
||||
} else if let invitation = getCallInvitation(callUUID) {
|
||||
endCall(invitation: invitation) { completed(true) }
|
||||
@@ -126,8 +126,8 @@ class CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
|
||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
|
||||
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
|
||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
|
||||
return invitation
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
||||
|
||||
struct IncomingCallView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var cc = CallController.shared
|
||||
|
||||
var body: some View {
|
||||
@@ -43,7 +44,7 @@ struct IncomingCallView: View {
|
||||
cc.endCall(invitation: invitation)
|
||||
}
|
||||
|
||||
callButton("Ignore", "multiply", .accentColor) {
|
||||
callButton("Ignore", "multiply", .primary) {
|
||||
cc.activeCallInvitation = nil
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ struct IncomingCallView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.modifier(ThemedBackground())
|
||||
.onAppear { dismissAllSheets() }
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ struct IncomingCallView: View {
|
||||
.frame(width: 24, height: 24)
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.frame(minWidth: 44)
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ class Call: ObservableObject, Equatable {
|
||||
|
||||
var direction: CallDirection
|
||||
var contact: Contact
|
||||
var callkitUUID: UUID?
|
||||
var callUUID: String?
|
||||
var localMedia: CallMediaType
|
||||
@Published var callState: CallState
|
||||
@Published var localCapabilities: CallCapabilities?
|
||||
@@ -33,14 +33,14 @@ class Call: ObservableObject, Equatable {
|
||||
init(
|
||||
direction: CallDirection,
|
||||
contact: Contact,
|
||||
callkitUUID: UUID?,
|
||||
callUUID: String?,
|
||||
callState: CallState,
|
||||
localMedia: CallMediaType,
|
||||
sharedKey: String? = nil
|
||||
) {
|
||||
self.direction = direction
|
||||
self.contact = contact
|
||||
self.callkitUUID = callkitUUID
|
||||
self.callUUID = callUUID
|
||||
self.callState = callState
|
||||
self.localMedia = localMedia
|
||||
self.sharedKey = sharedKey
|
||||
|
||||
@@ -411,6 +411,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
|
||||
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 }
|
||||
logger.debug("WebRTCClient: ending the call")
|
||||
activeCall.wrappedValue = nil
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
import SwiftUI
|
||||
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 {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
var imageSize: CGFloat = 32
|
||||
|
||||
@@ -26,29 +25,29 @@ struct ChatInfoToolbar: View {
|
||||
ChatInfoImage(
|
||||
chat: chat,
|
||||
size: imageSize,
|
||||
color: colorScheme == .dark
|
||||
? chatImageColorDark
|
||||
: chatImageColorLight
|
||||
color: Color(uiColor: .tertiaryLabel)
|
||||
)
|
||||
.padding(.trailing, 4)
|
||||
VStack {
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
|
||||
VStack(spacing: 0) {
|
||||
v
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
.padding(.top, -2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.frame(width: 220)
|
||||
}
|
||||
|
||||
private var contactVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.baselineOffset(1)
|
||||
.kerning(-2)
|
||||
}
|
||||
@@ -57,5 +56,6 @@ struct ChatInfoToolbar: View {
|
||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||
.environmentObject(CurrentColors.toAppTheme())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
class AnimatedImageView: UIView {
|
||||
var image: UIImage? = nil
|
||||
var imageView: UIImageView? = nil
|
||||
var cMode: UIView.ContentMode = .scaleAspectFit
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
||||
convenience init(image: UIImage) {
|
||||
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
|
||||
self.init()
|
||||
self.image = image
|
||||
self.cMode = contentMode
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
imageView!.contentMode = contentMode
|
||||
self.addSubview(imageView!)
|
||||
}
|
||||
|
||||
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
|
||||
if let subview = self.subviews.first as? UIImageView {
|
||||
if image.imageData != subview.gifImage?.imageData {
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
imageView!.contentMode = contentMode
|
||||
self.addSubview(imageView!)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
|
||||
|
||||
struct SwiftyGif: UIViewRepresentable {
|
||||
private let image: UIImage
|
||||
private let contentMode: UIView.ContentMode
|
||||
|
||||
init(image: UIImage) {
|
||||
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
|
||||
self.image = image
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> AnimatedImageView {
|
||||
AnimatedImageView(image: image)
|
||||
AnimatedImageView(image: image, contentMode: contentMode)
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
||||
|
||||
struct CICallItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var status: CICallStatus
|
||||
@@ -22,7 +23,7 @@ struct CICallItemView: View {
|
||||
switch status {
|
||||
case .pending:
|
||||
if sent {
|
||||
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
||||
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
acceptCallButton()
|
||||
}
|
||||
@@ -35,7 +36,7 @@ struct CICallItemView: View {
|
||||
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(.horizontal, 12)
|
||||
}
|
||||
@@ -52,7 +53,7 @@ struct CICallItemView: View {
|
||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||
HStack {
|
||||
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")
|
||||
}
|
||||
} 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 {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var im = ItemsModel.shared
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var feature: Feature
|
||||
@@ -52,8 +54,8 @@ struct CIChatFeatureView: View {
|
||||
var fs: [FeatureInfo] = []
|
||||
var icons: Set<String> = []
|
||||
if var i = m.getChatItemIndex(chatItem) {
|
||||
while i < m.reversedChatItems.count,
|
||||
let f = featureInfo(m.reversedChatItems[i]) {
|
||||
while i < im.reversedChatItems.count,
|
||||
let f = featureInfo(im.reversedChatItems[i]) {
|
||||
if !icons.contains(f.icon) {
|
||||
fs.insert(f, at: 0)
|
||||
icons.insert(f.icon)
|
||||
@@ -66,10 +68,10 @@ struct CIChatFeatureView: View {
|
||||
|
||||
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
||||
switch ci.content {
|
||||
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
||||
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
||||
case let .rcvGroupFeature(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, 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(theme.colors.secondary), 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(theme.colors.secondary), param)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
@@ -81,7 +83,7 @@ struct CIChatFeatureView: View {
|
||||
if let param = f.param {
|
||||
HStack {
|
||||
i
|
||||
chatEventText(Text(param)).lineLimit(1)
|
||||
chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
i
|
||||
@@ -93,7 +95,7 @@ struct CIChatFeatureView: View {
|
||||
Image(systemName: icon ?? feature.iconFilled)
|
||||
.foregroundColor(iconColor)
|
||||
.scaleEffect(feature.iconScale)
|
||||
chatEventText(chatItem)
|
||||
chatEventText(chatItem, theme.colors.secondary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
@@ -104,6 +106,6 @@ struct CIChatFeatureView: View {
|
||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), 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 {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
var feature: ChatFeature
|
||||
var allowed: FeatureAllowed
|
||||
@@ -19,7 +20,7 @@ struct CIFeaturePreferenceView: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: feature.icon)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.scaleEffect(feature.iconScale)
|
||||
if let ct = chat.chatInfo.contact,
|
||||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||
@@ -40,17 +41,17 @@ struct CIFeaturePreferenceView: View {
|
||||
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
||||
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if let acceptText {
|
||||
r = r
|
||||
+ Text(acceptText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
+ Text(" ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
return r.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,42 +11,48 @@ import SimpleXChat
|
||||
|
||||
struct CIFileView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let file: CIFile?
|
||||
let edited: Bool
|
||||
var smallViewSize: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.primary)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.secondary)
|
||||
if smallViewSize != nil {
|
||||
fileIndicator()
|
||||
.onTapGesture(perform: fileAction)
|
||||
} else {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
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)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
.disabled(!itemInteractive)
|
||||
}
|
||||
.disabled(!itemInteractive)
|
||||
}
|
||||
|
||||
private var itemInteractive: Bool {
|
||||
@@ -54,16 +60,18 @@ struct CIFileView: View {
|
||||
switch (file.fileStatus) {
|
||||
case .sndStored: return file.fileProtocol == .local
|
||||
case .sndTransfer: return false
|
||||
case .sndComplete: return false
|
||||
case .sndComplete: return true
|
||||
case .sndCancelled: return false
|
||||
case .sndError: return false
|
||||
case .sndError: return true
|
||||
case .sndWarning: return true
|
||||
case .rcvInvitation: return true
|
||||
case .rcvAccepted: return true
|
||||
case .rcvTransfer: return false
|
||||
case .rcvAborted: return true
|
||||
case .rcvComplete: return true
|
||||
case .rcvCancelled: return false
|
||||
case .rcvError: return false
|
||||
case .rcvError: return true
|
||||
case .rcvWarning: return true
|
||||
case .invalid: return false
|
||||
}
|
||||
}
|
||||
@@ -108,11 +116,40 @@ struct CIFileView: View {
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
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:
|
||||
logger.debug("CIFileView fileAction - in .sndStored")
|
||||
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -136,9 +173,10 @@ struct CIFileView: View {
|
||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||
case .sndCancelled: 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:
|
||||
if fileSizeValid(file) {
|
||||
fileIcon("arrow.down.doc.fill", color: .accentColor)
|
||||
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
|
||||
} else {
|
||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||
}
|
||||
@@ -150,10 +188,11 @@ struct CIFileView: View {
|
||||
progressView()
|
||||
}
|
||||
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 .rcvCancelled: 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)
|
||||
}
|
||||
} 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 {
|
||||
ZStack(alignment: .center) {
|
||||
let size = smallViewSize ?? 30
|
||||
return ZStack(alignment: .center) {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(color)
|
||||
if let innerIcon = innerIcon,
|
||||
let innerIconSize = innerIconSize {
|
||||
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
|
||||
Image(systemName: innerIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxHeight: 16)
|
||||
.frame(width: innerIconSize, height: innerIconSize)
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, 12)
|
||||
.padding(.top, size / 2.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import SimpleXChat
|
||||
|
||||
struct CIGroupInvitationView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var groupInvitation: CIGroupInvitation
|
||||
@@ -42,10 +43,10 @@ struct CIGroupInvitationView: View {
|
||||
.overlay(DetermineWidth())
|
||||
(
|
||||
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)
|
||||
+ 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())
|
||||
}
|
||||
@@ -53,7 +54,7 @@ struct CIGroupInvitationView: View {
|
||||
(
|
||||
groupInvitationText()
|
||||
+ 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())
|
||||
}
|
||||
@@ -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(.vertical, 6)
|
||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||
.textSelection(.disabled)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||
.onChange(of: inProgress) { inProgress in
|
||||
@@ -99,7 +99,7 @@ struct CIGroupInvitationView: View {
|
||||
private func groupInfoView(_ action: Bool) -> some View {
|
||||
var color: Color
|
||||
if action && !inProgress {
|
||||
color = chatIncognito ? .indigo : .accentColor
|
||||
color = chatIncognito ? .indigo : theme.colors.primary
|
||||
} else {
|
||||
color = Color(uiColor: .tertiaryLabel)
|
||||
}
|
||||
|
||||
@@ -11,29 +11,37 @@ import SimpleXChat
|
||||
|
||||
struct CIImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let chatItem: ChatItem
|
||||
let image: String
|
||||
var preview: UIImage?
|
||||
let maxWidth: CGFloat
|
||||
@Binding var imgWidth: CGFloat?
|
||||
@State var scrollProxy: ScrollViewProxy?
|
||||
@State private var showFullScreenImage = false
|
||||
var imgWidth: CGFloat?
|
||||
var smallView: Bool = false
|
||||
@Binding var showFullScreenImage: Bool
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
if let uiImage = getLoadedImage(file) {
|
||||
imageView(uiImage)
|
||||
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||
.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 }
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenImage = false
|
||||
}
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
} else if let preview {
|
||||
Group {
|
||||
if smallView {
|
||||
smallViewImageView(preview)
|
||||
} else {
|
||||
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
@@ -60,17 +68,39 @@ struct CIImageView: View {
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
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: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
showFullScreenImage = false
|
||||
}
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { imgWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
if img.imageData == nil {
|
||||
Image(uiImage: img)
|
||||
@@ -82,7 +112,26 @@ struct CIImageView: View {
|
||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||
.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 .sndCancelled: 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 .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case .rcvTransfer: progressView()
|
||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||
case .rcvComplete: EmptyView()
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvError: fileIcon("xmark", 10, 13)
|
||||
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,4 +178,12 @@ struct CIImageView: View {
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||
switch fileStatus {
|
||||
case .rcvInvitation: true
|
||||
case .rcvAborted: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CIInvalidJSONView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var json: String
|
||||
@State private var showJSON = false
|
||||
|
||||
@@ -21,10 +22,9 @@ struct CIInvalidJSONView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture { showJSON = true }
|
||||
.sheet(isPresented: $showJSON) {
|
||||
.appSheet(isPresented: $showJSON) {
|
||||
invalidJSONView(json)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ func invalidJSONView(_ json: String) -> some View {
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding()
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
struct CIInvalidJSONView_Previews: PreviewProvider {
|
||||
|
||||
@@ -10,16 +10,17 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CILinkView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let linkPreview: LinkPreview
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(linkPreview.title)
|
||||
@@ -32,7 +33,7 @@ struct CILinkView: View {
|
||||
Text(linkPreview.uri.absoluteString)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
||||
|
||||
struct CIMemberCreatedContactView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
@@ -43,12 +44,12 @@ struct CIMemberCreatedContactView: View {
|
||||
r = r
|
||||
+ Text(openText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
+ Text(" ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
return r.font(.caption)
|
||||
}
|
||||
|
||||
@@ -56,11 +57,11 @@ struct CIMemberCreatedContactView: View {
|
||||
if let member = chatItem.memberDisplayName {
|
||||
return Text(member + " " + chatItem.content.text + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
return Text(chatItem.content.text + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import SimpleXChat
|
||||
|
||||
struct CIMetaView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
var chatItem: ChatItem
|
||||
var metaColor = Color.secondary
|
||||
var metaColor: Color
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
var showStatus = true
|
||||
var showEdited = true
|
||||
@@ -29,24 +31,24 @@ struct CIMetaView: View {
|
||||
switch meta.itemStatus {
|
||||
case let .sndSent(sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
case .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, showTimesamp: showTimestamp)
|
||||
}
|
||||
case let .sndRcvd(_, sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
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, showTimesamp: showTimestamp)
|
||||
}
|
||||
case .partial:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||
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, showTimesamp: showTimestamp)
|
||||
}
|
||||
}
|
||||
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?,
|
||||
encrypted: Bool?,
|
||||
color: Color = .clear,
|
||||
primaryColor: Color = .accentColor,
|
||||
transparent: Bool = false,
|
||||
sent: SentCheckmark? = nil,
|
||||
showStatus: Bool = true,
|
||||
showEdited: Bool = true,
|
||||
showViaProxy: Bool
|
||||
showViaProxy: Bool,
|
||||
showTimesamp: Bool
|
||||
) -> Text {
|
||||
var r = Text("")
|
||||
if showEdited, meta.itemEdited {
|
||||
@@ -85,7 +89,7 @@ func ciMetaText(
|
||||
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
||||
}
|
||||
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 gap = Text(" ").kerning(-1.25)
|
||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||
@@ -103,7 +107,9 @@ func ciMetaText(
|
||||
if let enc = encrypted {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -112,15 +118,16 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
}
|
||||
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
static let metaColor = Color.secondary
|
||||
static var previews: some View {
|
||||
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: .partial)))
|
||||
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: .partial)))
|
||||
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", .sndSent(sndProgress: .complete), itemEdited: true))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
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)), metaColor: metaColor)
|
||||
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)), metaColor: metaColor)
|
||||
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), metaColor: metaColor)
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 100))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
|
||||
|
||||
struct CIRcvDecryptionError: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
var msgDecryptError: MsgDecryptError
|
||||
var msgCount: UInt32
|
||||
var chatItem: ChatItem
|
||||
@@ -26,7 +28,7 @@ struct CIRcvDecryptionError: View {
|
||||
case syncNotSupportedContactAlert
|
||||
case syncNotSupportedMemberAlert
|
||||
case decryptionErrorAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -61,43 +63,46 @@ struct CIRcvDecryptionError: View {
|
||||
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 .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 {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn?.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
Group {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn?.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
}
|
||||
} else if !contactStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedContactAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else if !contactStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedContactAlert
|
||||
} 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()
|
||||
}
|
||||
} 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 {
|
||||
@@ -114,24 +119,22 @@ struct CIRcvDecryptionError: View {
|
||||
}
|
||||
(
|
||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ Text("Fix connection")
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||
.font(.callout)
|
||||
+ 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)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
@@ -142,16 +145,14 @@ struct CIRcvDecryptionError: View {
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
+ 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)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,61 +13,51 @@ import Combine
|
||||
|
||||
struct CIVideoView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private let chatItem: ChatItem
|
||||
private let image: String
|
||||
private let preview: UIImage?
|
||||
@State private var duration: Int
|
||||
@State private var progress: Int = 0
|
||||
@State private var videoPlaying: Bool = false
|
||||
private let maxWidth: CGFloat
|
||||
@Binding private var videoWidth: CGFloat?
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
@State private var preview: UIImage? = nil
|
||||
private var videoWidth: CGFloat?
|
||||
private let smallView: Bool
|
||||
@State private var player: AVPlayer?
|
||||
@State private var fullPlayer: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@State private var urlDecrypted: URL?
|
||||
@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 fullScreenTimeObserver: Any? = 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.image = image
|
||||
self.preview = preview
|
||||
self._duration = State(initialValue: duration)
|
||||
self.maxWidth = maxWidth
|
||||
self._videoWidth = videoWidth
|
||||
self.scrollProxy = scrollProxy
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
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)
|
||||
}
|
||||
self.videoWidth = videoWidth
|
||||
self.smallView = smallView
|
||||
self._showFullScreenPlayer = showFullscreenPlayer
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
ZStack {
|
||||
ZStack(alignment: smallView ? .topLeading : .center) {
|
||||
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)
|
||||
} 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 {
|
||||
videoViewEncrypted(file, defaultPreview, duration)
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
} else if let preview, let file {
|
||||
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||
.onTapGesture {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
@@ -91,21 +81,64 @@ struct CIVideoView: View {
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
durationProgress()
|
||||
if !smallView {
|
||||
durationProgress()
|
||||
}
|
||||
}
|
||||
if let file = file, showDownloadButton(file.fileStatus) {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
if !blurred, let file, showDownloadButton(file.fileStatus) {
|
||||
if !smallView {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
} else if !file.showStatusIconInSmallView {
|
||||
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 {
|
||||
case .rcvInvitation: true
|
||||
case .rcvAborted: true
|
||||
@@ -118,11 +151,6 @@ struct CIVideoView: View {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
imageView(defaultPreview)
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
if let decrypted = urlDecrypted {
|
||||
fullScreenPlayer(decrypted)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
decrypt(file: file) {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
@@ -131,20 +159,22 @@ struct CIVideoView: View {
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
if !blurred {
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
.disabled(!canBePlayed)
|
||||
} 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 {
|
||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
@@ -164,9 +193,7 @@ struct CIVideoView: View {
|
||||
videoPlaying = false
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
fullScreenPlayer(url)
|
||||
}
|
||||
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
||||
.onTapGesture {
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
@@ -182,7 +209,7 @@ struct CIVideoView: View {
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !videoPlaying {
|
||||
if !videoPlaying && !blurred {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
@@ -192,7 +219,7 @@ struct CIVideoView: View {
|
||||
.disabled(!canBePlayed)
|
||||
}
|
||||
}
|
||||
loadingIndicator()
|
||||
fileStatusIcon()
|
||||
}
|
||||
.onAppear {
|
||||
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 {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.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)
|
||||
.padding(.leading, 4)
|
||||
.frame(width: 40, height: 40)
|
||||
.padding(.leading, smallView ? 0 : 4)
|
||||
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
@@ -219,9 +285,9 @@ struct CIVideoView: View {
|
||||
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 12, height: 12)
|
||||
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
|
||||
.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))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
@@ -252,17 +318,32 @@ struct CIVideoView: View {
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.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 {
|
||||
switch file.fileStatus {
|
||||
case .sndStored:
|
||||
@@ -279,7 +360,22 @@ struct CIVideoView: View {
|
||||
}
|
||||
case .sndComplete: fileIcon("checkmark", 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 .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||
@@ -289,10 +385,25 @@ struct CIVideoView: View {
|
||||
progressView()
|
||||
}
|
||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||
case .rcvComplete: EmptyView()
|
||||
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)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,7 +414,7 @@ struct CIVideoView: View {
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
.padding(smallView ? 0 : padding)
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
@@ -311,7 +422,7 @@ struct CIVideoView: View {
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 16, height: 16)
|
||||
.tint(.white)
|
||||
.padding(11)
|
||||
.padding(smallView ? 0 : 11)
|
||||
}
|
||||
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
@@ -323,7 +434,7 @@ struct CIVideoView: View {
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.padding([.trailing, .top], 11)
|
||||
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||
}
|
||||
|
||||
// TODO encrypt: where file size is checked?
|
||||
@@ -363,7 +474,8 @@ struct CIVideoView: View {
|
||||
)
|
||||
.onAppear {
|
||||
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 {
|
||||
player.play()
|
||||
var played = false
|
||||
@@ -400,10 +512,12 @@ struct CIVideoView: View {
|
||||
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
||||
await MainActor.run {
|
||||
if let decrypted = urlDecrypted {
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
if !smallView {
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
}
|
||||
fullPlayer = AVPlayer(url: decrypted)
|
||||
}
|
||||
decryptionInProgress = true
|
||||
decryptionInProgress = false
|
||||
completed?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,30 @@ import SimpleXChat
|
||||
|
||||
struct CIVoiceView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
@State var audioPlayer: AudioPlayer? = nil
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval? = nil
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
var smallViewSize: CGFloat?
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
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) {
|
||||
HStack {
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
@@ -53,7 +65,13 @@ struct CIVoiceView: 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,
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
@@ -62,7 +80,8 @@ struct CIVoiceView: View {
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu
|
||||
allowMenu: $allowMenu,
|
||||
sizeMultiplier: sizeMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +91,7 @@ struct CIVoiceView: View {
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
private func playbackSlider() -> some View {
|
||||
@@ -89,10 +108,11 @@ struct CIVoiceView: View {
|
||||
allowMenu = true
|
||||
}
|
||||
}
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
|
||||
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 {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
var recordingFile: CIFile?
|
||||
var recordingTime: TimeInterval
|
||||
@@ -128,24 +149,61 @@ struct VoiceMessagePlayer: View {
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
var sizeMultiplier: CGFloat
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let recordingFile = recordingFile {
|
||||
switch recordingFile.fileStatus {
|
||||
case .sndStored: playbackButton()
|
||||
case .sndTransfer: playbackButton()
|
||||
case .sndStored:
|
||||
if recordingFile.fileProtocol == .local {
|
||||
playbackButton()
|
||||
} else {
|
||||
loadingIcon()
|
||||
}
|
||||
case .sndTransfer: loadingIcon()
|
||||
case .sndComplete: 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 .rcvAccepted: loadingIcon()
|
||||
case .rcvTransfer: loadingIcon()
|
||||
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
||||
case .rcvComplete: playbackButton()
|
||||
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))
|
||||
}
|
||||
} else {
|
||||
@@ -153,84 +211,170 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
}
|
||||
.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) }
|
||||
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 = {
|
||||
playbackState = .noPlayback
|
||||
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
|
||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
.onChange(of: playbackState) { state in
|
||||
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 {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
if sizeMultiplier != 1 {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
.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 {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
if recordingTime > 0 {
|
||||
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 {
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
Group {
|
||||
if sizeMultiplier != 1 {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
.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 {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var length: TimeInterval
|
||||
@Binding var progress: TimeInterval?
|
||||
|
||||
@@ -238,7 +382,7 @@ struct VoiceMessagePlayer: View {
|
||||
Circle()
|
||||
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
||||
.stroke(
|
||||
Color.accentColor,
|
||||
theme.colors.primary,
|
||||
style: StrokeStyle(lineWidth: 3)
|
||||
)
|
||||
.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 {
|
||||
ProgressView()
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
||||
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
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(
|
||||
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: {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
notifyStateChange()
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||
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 {
|
||||
@@ -290,15 +508,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
audioPlayer: .constant(nil),
|
||||
playbackState: .constant(.playing),
|
||||
playbackTime: .constant(TimeInterval(20)),
|
||||
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: ChatItem.getVoiceMsgContentSample(), 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), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, 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))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
|
||||
@@ -10,22 +10,21 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct DeletedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.italic()
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.background(chatItemFrameColor(chatItem, theme))
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||