Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387faa0c27 | |||
| aa990da17c | |||
| a48c82f4a1 | |||
| f41c04735b | |||
| a8da9b9cd9 | |||
| 64a0f509f7 | |||
| c4f8a50f0d | |||
| 93a4c0854e | |||
| 49c29c74df | |||
| e6ee5df158 | |||
| f4be0278b6 | |||
| a9d2535292 | |||
| 3e623684bc | |||
| fd90b47194 | |||
| acd3467d10 | |||
| 71ce598355 | |||
| 63393eaf0b | |||
| f90de83215 | |||
| 1c10209a31 | |||
| 5d7abf31ce | |||
| 44c0861fe4 | |||
| 1e6dc8002c | |||
| 0509ff9a1b | |||
| f12e504828 | |||
| 6a273677f1 | |||
| f5eea018d9 | |||
| 5f96f6c117 | |||
| e963ef53e7 | |||
| 1180edf80e | |||
| 4bd6517d19 | |||
| 2585f4ecfd | |||
| e22e01acd2 | |||
| 8e4299afb6 | |||
| ecff3c6ee5 | |||
| 85af368371 | |||
| 0d3928bd51 | |||
| ddeaa1c7c3 | |||
| 00ba468898 | |||
| 593c7d247c | |||
| 5907d8bd0c | |||
| 231082860e | |||
| c6366cb269 | |||
| d066c66282 | |||
| fab3827697 | |||
| d8126e0cd0 | |||
| 5baf8789d6 | |||
| dd5454f390 | |||
| fca098f535 | |||
| fba0478e50 | |||
| d951003191 | |||
| 1af513c548 | |||
| 85dc467f74 | |||
| 89e65c2fc1 | |||
| 0fa8d77214 | |||
| 6ba82c8c39 | |||
| e19c14b249 | |||
| d993381d31 | |||
| 0198c1f110 | |||
| 6c4c9b172c | |||
| d9a61af401 | |||
| a58522ef15 | |||
| bcd510c913 | |||
| c4083e00c5 | |||
| 7cd35b9068 | |||
| d3274b28ba | |||
| 40bd5dc2da | |||
| 1573b7af13 | |||
| a5bf3cbca8 | |||
| d5529031ee | |||
| d244338b13 | |||
| ce55c84b96 | |||
| c52f9baefa | |||
| ea1c5da65d | |||
| 5695bb9d46 | |||
| 13751d5705 | |||
| 02803b1491 | |||
| 222af69a71 | |||
| 5e96e1ea2b | |||
| 490e8cead8 | |||
| f578ee843b | |||
| cf2eb76cb7 | |||
| 48f9dcab9a | |||
| ac2ef54838 | |||
| 7e269c6b8c | |||
| 3e739924bb | |||
| 83706b19da | |||
| 6f19a7a20f | |||
| a8740cee4b | |||
| 4000fe383d | |||
| 75445e604e | |||
| 0596278e50 | |||
| 7a77775a42 | |||
| dc8d08ec04 | |||
| 4ad0e0f65e | |||
| d01515e755 | |||
| 3fa70ba217 | |||
| 6ae4cddc2e | |||
| ac8ee971f5 | |||
| 11d4872186 | |||
| 370e2e87ad | |||
| ef30fcf5e2 | |||
| 6ad1423f29 | |||
| c52e5349db | |||
| 3c98c6acc0 | |||
| c251f5e2bd | |||
| b165603136 | |||
| 184a24b70e | |||
| d46bae6498 | |||
| 5ac521f6d8 | |||
| 7b5b747b19 | |||
| 037655e30f | |||
| 54088c6d5a | |||
| 78f7fcc89f | |||
| b402caeb89 | |||
| ff8a8de3c8 | |||
| d22c0c2614 | |||
| 73c8d3c2e3 | |||
| 0110385e65 | |||
| ad0a678c5f | |||
| 0495a4cd78 | |||
| 02267c5263 | |||
| cad3bc048f | |||
| 1b04423745 | |||
| 2143eb2d7a | |||
| 3d395b0e45 | |||
| d79b4dfb5a | |||
| 0ae9f5ebba | |||
| 57c80786dc | |||
| da1cad96fc | |||
| b58ccedf23 | |||
| 01cadefde7 | |||
| c70e7223d9 | |||
| a1d4f52185 | |||
| 373070f388 | |||
| a03ccd0ba6 | |||
| a7982cccf9 | |||
| dccd6631af | |||
| b0516b6440 | |||
| bf91666f07 | |||
| ab47a5a27e | |||
| 23581f59ae | |||
| 291df6e9d0 | |||
| dae0b63c22 | |||
| 0964982782 | |||
| 9eb6034e1a |
@@ -158,20 +158,19 @@ We are prioritizing users privacy and security - it would be impossible without
|
|||||||
|
|
||||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||||
|
|
||||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||||
|
|
||||||
It is possible to donate via:
|
It is possible to donate via:
|
||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
|
||||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
|
||||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
|
||||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- USDT:
|
- USDT:
|
||||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
|
||||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- please ask if you want to donate any other coins.
|
||||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
|
|
||||||
@@ -234,6 +233,8 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
Recent and important updates:
|
Recent and important updates:
|
||||||
|
|
||||||
|
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
|
||||||
|
|
||||||
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
|
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
|
||||||
|
|
||||||
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
|
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
|
||||||
@@ -382,10 +383,10 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
|||||||
- ✅ Private notes.
|
- ✅ Private notes.
|
||||||
- ✅ Improve sending videos (including encryption of locally stored videos).
|
- ✅ Improve sending videos (including encryption of locally stored videos).
|
||||||
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
||||||
|
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||||
- 🏗 Improve stability and reduce battery usage.
|
- 🏗 Improve stability and reduce battery usage.
|
||||||
- 🏗 Improve experience for the new users.
|
- 🏗 Improve experience for the new users.
|
||||||
- 🏗 Large groups, communities and public channels.
|
- 🏗 Large groups, communities and public channels.
|
||||||
- 🏗 Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
|
||||||
- Privacy & security slider - a simple way to set all settings at once.
|
- Privacy & security slider - a simple way to set all settings at once.
|
||||||
- SMP queue redundancy and rotation (manual is supported).
|
- SMP queue redundancy and rotation (manual is supported).
|
||||||
- Include optional message into connection request sent via contact address.
|
- Include optional message into connection request sent via contact address.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
@@ -17,6 +18,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||||
removePasscodesIfReinstalled()
|
removePasscodesIfReinstalled()
|
||||||
|
prepareForLaunch()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +143,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func prepareForLaunch() {
|
||||||
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
static func keepScreenOn(_ on: Bool) {
|
static func keepScreenOn(_ on: Bool) {
|
||||||
UIApplication.shared.isIdleTimerDisabled = on
|
UIApplication.shared.isIdleTimerDisabled = on
|
||||||
}
|
}
|
||||||
@@ -148,13 +154,79 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
|
|
||||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
static var windowStatic: UIWindow?
|
||||||
var windowScene: UIWindowScene?
|
var windowScene: UIWindowScene?
|
||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
UITableView.appearance().backgroundColor = .clear
|
||||||
guard let windowScene = scene as? UIWindowScene else { return }
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
self.windowScene = windowScene
|
self.windowScene = windowScene
|
||||||
window = windowScene.keyWindow
|
window = windowScene.keyWindow
|
||||||
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
|
SceneDelegate.windowStatic = windowScene.keyWindow
|
||||||
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
|
migrateAccentColorAndTheme()
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrateAccentColorAndTheme() {
|
||||||
|
let defs = UserDefaults.standard
|
||||||
|
/// For checking migration
|
||||||
|
// themeOverridesDefault.set([])
|
||||||
|
// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
|
||||||
|
let userInterfaceStyle = getUserInterfaceStyleDefault()
|
||||||
|
if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
|
||||||
|
// No migration needed or already migrated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
|
||||||
|
let accentColor = Color(cgColor: getUIAccentColorDefault())
|
||||||
|
if accentColor != defaultAccentColor {
|
||||||
|
let colors = ThemeColors(primary: accentColor.toReadableHex())
|
||||||
|
var overrides = themeOverridesDefault.get()
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
switch userInterfaceStyle {
|
||||||
|
case .light:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
|
||||||
|
case .dark:
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
|
||||||
|
case .unspecified:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
@unknown default: ()
|
||||||
|
}
|
||||||
|
} else if userInterfaceStyle != .unspecified {
|
||||||
|
let themeName = switch userInterfaceStyle {
|
||||||
|
case .light: DefaultTheme.LIGHT.themeName
|
||||||
|
case .dark: DefaultTheme.DARK.themeName
|
||||||
|
default: DefaultTheme.SYSTEM_THEME_NAME
|
||||||
|
}
|
||||||
|
ThemeManager.applyTheme(themeName)
|
||||||
|
}
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -14,6 +14,8 @@ struct ContentView: View {
|
|||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@ObservedObject var callController = CallController.shared
|
@ObservedObject var callController = CallController.shared
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||||
|
|
||||||
var contentAccessAuthenticationExtended: Bool
|
var contentAccessAuthenticationExtended: Bool
|
||||||
|
|
||||||
@@ -51,6 +53,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
allViews()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
} else {
|
||||||
|
// on iOS 15 scroll view background disabled in SceneDelegate
|
||||||
|
allViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func allViews() -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
||||||
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||||
@@ -138,6 +150,17 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
reactOnDarkThemeChanges()
|
||||||
|
}
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
.onChange(of: theme.name) { _ in
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contentView() -> some View {
|
@ViewBuilder private func contentView() -> some View {
|
||||||
@@ -224,8 +247,8 @@ struct ContentView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||||
.background(
|
.background(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.background)
|
.fill(theme.colors.background)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mainView() -> some View {
|
private func mainView() -> some View {
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var onboardingStage: OnboardingStage?
|
@Published var onboardingStage: OnboardingStage?
|
||||||
@Published var setDeliveryReceipts = false
|
@Published var setDeliveryReceipts = false
|
||||||
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||||
@Published var currentUser: User?
|
@Published var currentUser: User? {
|
||||||
|
didSet {
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
@Published var users: [UserInfo] = []
|
@Published var users: [UserInfo] = []
|
||||||
@Published var chatInitialized = false
|
@Published var chatInitialized = false
|
||||||
@Published var chatRunning: Bool?
|
@Published var chatRunning: Bool?
|
||||||
@@ -69,6 +73,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||||
@Published var chatToTop: String?
|
@Published var chatToTop: String?
|
||||||
@Published var groupMembers: [GMember] = []
|
@Published var groupMembers: [GMember] = []
|
||||||
|
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
|
||||||
// items in the terminal view
|
// items in the terminal view
|
||||||
@Published var showingTerminal = false
|
@Published var showingTerminal = false
|
||||||
@Published var terminalItems: [TerminalItem] = []
|
@Published var terminalItems: [TerminalItem] = []
|
||||||
@@ -176,8 +181,18 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func populateGroupMembersIndexes() {
|
||||||
|
groupMembersIndexes.removeAll()
|
||||||
|
for (i, member) in groupMembers.enumerated() {
|
||||||
|
groupMembersIndexes[member.groupMemberId] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
||||||
groupMembers.first { $0.groupMemberId == groupMemberId }
|
if let i = groupMembersIndexes[groupMemberId] {
|
||||||
|
return groupMembers[i]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getChatIndex(_ id: String) -> Int? {
|
private func getChatIndex(_ id: String) -> Int? {
|
||||||
@@ -332,12 +347,12 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||||
if let i = getChatItemIndex(cItem) {
|
if let i = getChatItemIndex(cItem) {
|
||||||
withAnimation {
|
withConditionalAnimation {
|
||||||
_updateChatItem(at: i, with: cItem)
|
_updateChatItem(at: i, with: cItem)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation(itemAnimation()) {
|
withConditionalAnimation(itemAnimation()) {
|
||||||
var ci = cItem
|
var ci = cItem
|
||||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||||
ci.meta.itemStatus = status
|
ci.meta.itemStatus = status
|
||||||
@@ -357,7 +372,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||||
withAnimation {
|
withConditionalAnimation {
|
||||||
_updateChatItem(at: i, with: cItem)
|
_updateChatItem(at: i, with: cItem)
|
||||||
}
|
}
|
||||||
} else if let status = status {
|
} else if let status = status {
|
||||||
@@ -422,6 +437,16 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
|
||||||
|
guard var current = currentUser else { return }
|
||||||
|
current.uiThemes = uiThemes
|
||||||
|
let i = users.firstIndex(where: { $0.user.userId == current.userId })
|
||||||
|
if let i {
|
||||||
|
users[i].user = current
|
||||||
|
}
|
||||||
|
currentUser = current
|
||||||
|
}
|
||||||
|
|
||||||
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
||||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -512,11 +537,13 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
// update preview
|
|
||||||
decreaseUnreadCounter(cInfo)
|
|
||||||
// update current chat
|
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||||
markChatItemRead_(i)
|
if reversedChatItems[i].isRcvNew {
|
||||||
|
// update current chat
|
||||||
|
markChatItemRead_(i)
|
||||||
|
// update preview
|
||||||
|
decreaseUnreadCounter(cInfo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,14 +678,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
if chatId == groupInfo.id {
|
if chatId == groupInfo.id {
|
||||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
if let i = groupMembersIndexes[member.groupMemberId] {
|
||||||
withAnimation(.default) {
|
withAnimation(.default) {
|
||||||
self.groupMembers[i].wrapped = member
|
self.groupMembers[i].wrapped = member
|
||||||
self.groupMembers[i].created = Date.now
|
self.groupMembers[i].created = Date.now
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation { groupMembers.append(GMember(member)) }
|
withAnimation {
|
||||||
|
groupMembers.append(GMember(member))
|
||||||
|
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -686,7 +716,7 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
|
return UnreadChatItemCounts(isNearBottom: totalBelow < 16, unreadBelow: unreadBelow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
||||||
@@ -723,8 +753,8 @@ struct NTFContactRequest {
|
|||||||
var chatId: String
|
var chatId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnreadChatItemCounts {
|
struct UnreadChatItemCounts: Equatable {
|
||||||
var totalBelow: Int
|
var isNearBottom: Bool
|
||||||
var unreadBelow: Int
|
var unreadBelow: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,43 @@ func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveWallpaperFile(url: URL) -> String? {
|
||||||
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
|
||||||
|
return destFile.lastPathComponent
|
||||||
|
} catch {
|
||||||
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveWallpaperFile(image: UIImage) -> String? {
|
||||||
|
let hasAlpha = imageHasAlpha(image)
|
||||||
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
|
||||||
|
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
|
||||||
|
do {
|
||||||
|
try dataResized!.write(to: destFile)
|
||||||
|
return destFile.lastPathComponent
|
||||||
|
} catch {
|
||||||
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeWallpaperFile(fileName: String? = nil) {
|
||||||
|
do {
|
||||||
|
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
|
||||||
|
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
if let fileName {
|
||||||
|
WallpaperType.cachedImages.removeValue(forKey: fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
||||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,22 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
|
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse {
|
||||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
if log {
|
||||||
|
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||||
|
}
|
||||||
let start = Date.now
|
let start = Date.now
|
||||||
let resp = bgTask
|
let resp = bgTask
|
||||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
||||||
: sendSimpleXCmd(cmd, ctrl)
|
: sendSimpleXCmd(cmd, ctrl)
|
||||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
if log {
|
||||||
if case let .response(_, json) = resp {
|
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
if case let .response(_, json) = resp {
|
||||||
}
|
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||||
Task {
|
}
|
||||||
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
Task {
|
||||||
|
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
@@ -242,14 +246,8 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
|
|||||||
logger.error("apiSuspendChat error: \(String(describing: r))")
|
logger.error("apiSuspendChat error: \(String(describing: r))")
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
|
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||||
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
|
let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl)
|
||||||
if case .cmdOk = r { return }
|
|
||||||
throw r
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
|
|
||||||
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
|
|
||||||
if case .cmdOk = r { return }
|
if case .cmdOk = r { return }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@@ -309,8 +307,10 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let loadItemsPerPage = 50
|
||||||
|
|
||||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
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 r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@@ -541,6 +541,11 @@ func reconnectAllServers() async throws {
|
|||||||
try await sendCommandOkResp(.reconnectAllServers)
|
try await sendCommandOkResp(.reconnectAllServers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reconnectServer(smpServer: String) async throws {
|
||||||
|
let userId = try currentUserId("reconnectServer")
|
||||||
|
try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer))
|
||||||
|
}
|
||||||
|
|
||||||
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
||||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||||
}
|
}
|
||||||
@@ -561,6 +566,18 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Gro
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
|
||||||
|
let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
|
||||||
|
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
|
||||||
|
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||||
|
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
|
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
|
||||||
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
|
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
|
||||||
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
|
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
|
||||||
@@ -819,6 +836,21 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
|
||||||
|
let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes))
|
||||||
|
if case .cmdOk = r { return true }
|
||||||
|
logger.error("apiSetUserUIThemes bad response: \(String(describing: r))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool {
|
||||||
|
let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes))
|
||||||
|
if case .cmdOk = r { return true }
|
||||||
|
logger.error("apiSetChatUIThemes bad response: \(String(describing: r))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func apiCreateUserAddress() async throws -> String {
|
func apiCreateUserAddress() async throws -> String {
|
||||||
let userId = try currentUserId("apiCreateUserAddress")
|
let userId = try currentUserId("apiCreateUserAddress")
|
||||||
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
|
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
|
||||||
@@ -1265,7 +1297,7 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
|
|||||||
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
|
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
|
||||||
return ChatModel.shared.chats
|
return ChatModel.shared.chats
|
||||||
.compactMap{ $0.chatInfo.contact }
|
.compactMap{ $0.chatInfo.contact }
|
||||||
.filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) }
|
.filter{ c in c.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) }
|
||||||
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
|
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,6 +1354,18 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getAgentServersSummary() throws -> PresentedServersSummary {
|
||||||
|
let userId = try currentUserId("getAgentServersSummary")
|
||||||
|
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
|
||||||
|
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
|
||||||
|
logger.error("getAgentServersSummary error: \(String(describing: r))")
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAgentServersStats() async throws {
|
||||||
|
try await sendCommandOkResp(.resetAgentServersStats)
|
||||||
|
}
|
||||||
|
|
||||||
private func currentUserId(_ funcName: String) throws -> Int64 {
|
private func currentUserId(_ funcName: String) throws -> Int64 {
|
||||||
if let userId = ChatModel.shared.currentUser?.userId {
|
if let userId = ChatModel.shared.currentUser?.userId {
|
||||||
return userId
|
return userId
|
||||||
@@ -1341,8 +1385,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
|
|||||||
if encryptionStartedDefault.get() {
|
if encryptionStartedDefault.get() {
|
||||||
encryptionStartedDefault.set(false)
|
encryptionStartedDefault.set(false)
|
||||||
}
|
}
|
||||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path)
|
||||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
|
||||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||||
m.chatInitialized = true
|
m.chatInitialized = true
|
||||||
m.currentUser = try apiGetActiveUser()
|
m.currentUser = try apiGetActiveUser()
|
||||||
@@ -1427,8 +1470,7 @@ func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
|
|||||||
logger.debug("startChatWithTemporaryDatabase")
|
logger.debug("startChatWithTemporaryDatabase")
|
||||||
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
|
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
|
||||||
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
|
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
|
||||||
try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
|
try apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl)
|
||||||
try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
|
|
||||||
_ = try apiStartChat(ctrl: ctrl)
|
_ = try apiStartChat(ctrl: ctrl)
|
||||||
return migrationActiveUser
|
return migrationActiveUser
|
||||||
}
|
}
|
||||||
@@ -1814,11 +1856,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
if let aChatItem = aChatItem {
|
if let aChatItem = aChatItem {
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
}
|
}
|
||||||
case let .rcvFileError(user, aChatItem, _):
|
case let .rcvFileError(user, aChatItem, _, _):
|
||||||
if let aChatItem = aChatItem {
|
if let aChatItem = aChatItem {
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
Task { cleanupFile(aChatItem) }
|
Task { cleanupFile(aChatItem) }
|
||||||
}
|
}
|
||||||
|
case let .rcvFileWarning(user, aChatItem, _, _):
|
||||||
|
if let aChatItem = aChatItem {
|
||||||
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
|
}
|
||||||
case let .sndFileStart(user, aChatItem, _):
|
case let .sndFileStart(user, aChatItem, _):
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
case let .sndFileComplete(user, aChatItem, _):
|
case let .sndFileComplete(user, aChatItem, _):
|
||||||
@@ -1835,11 +1881,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
}
|
}
|
||||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
case let .sndFileError(user, aChatItem, _):
|
case let .sndFileError(user, aChatItem, _, _):
|
||||||
if let aChatItem = aChatItem {
|
if let aChatItem = aChatItem {
|
||||||
await chatItemSimpleUpdate(user, aChatItem)
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
Task { cleanupFile(aChatItem) }
|
Task { cleanupFile(aChatItem) }
|
||||||
}
|
}
|
||||||
|
case let .sndFileWarning(user, aChatItem, _, _):
|
||||||
|
if let aChatItem = aChatItem {
|
||||||
|
await chatItemSimpleUpdate(user, aChatItem)
|
||||||
|
}
|
||||||
case let .callInvitation(invitation):
|
case let .callInvitation(invitation):
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.callInvitations[invitation.contact.id] = invitation
|
m.callInvitations[invitation.contact.id] = invitation
|
||||||
@@ -1937,12 +1987,28 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
|
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
|
||||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
||||||
}
|
}
|
||||||
case .remoteCtrlStopped:
|
case let .remoteCtrlStopped(_, rcStopReason):
|
||||||
// This delay is needed to cancel the session that fails on network failure,
|
// This delay is needed to cancel the session that fails on network failure,
|
||||||
// e.g. when user did not grant permission to access local network yet.
|
// e.g. when user did not grant permission to access local network yet.
|
||||||
if let sess = m.remoteCtrlSession {
|
if let sess = m.remoteCtrlSession {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.remoteCtrlSession = nil
|
m.remoteCtrlSession = nil
|
||||||
|
dismissAllSheets() {
|
||||||
|
switch rcStopReason {
|
||||||
|
case .connectionFailed(.errorAgent(.RCP(.identity))):
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: "Connection with desktop stopped",
|
||||||
|
message: "This link was used with another mobile device, please create a new link on the desktop."
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Connection with desktop stopped"),
|
||||||
|
message: Text("Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers."),
|
||||||
|
primaryButton: .default(Text("Ok")),
|
||||||
|
secondaryButton: .default(Text("Copy error")) { UIPasteboard.general.string = String(describing: rcStopReason) }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if case .connected = sess.sessionState {
|
if case .connected = sess.sessionState {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct SimpleXApp: App {
|
|||||||
// so that it's computed by the time view renders, and not on event after rendering
|
// so that it's computed by the time view renders, and not on event after rendering
|
||||||
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||||
.environmentObject(chatModel)
|
.environmentObject(chatModel)
|
||||||
|
.environmentObject(AppTheme.shared)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
logger.debug("ContentView.onOpenURL: \(url)")
|
logger.debug("ContentView.onOpenURL: \(url)")
|
||||||
chatModel.appOpenUrl = url
|
chatModel.appOpenUrl = url
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//
|
||||||
|
// 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() {
|
||||||
|
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
|
||||||
|
// Change active colors from light to dark and back based on system theme
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ThemeWallpaper {
|
||||||
|
public func importFromString() -> ThemeWallpaper {
|
||||||
|
if preset == nil, let image {
|
||||||
|
// Need to save image from string and to save its path
|
||||||
|
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||||
|
let parsed = UIImage(data: data),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// MPVolumeView.swift
|
// MPVolumeView.swift
|
||||||
// SimpleX (iOS)
|
// SimpleX (iOS)
|
||||||
//
|
//
|
||||||
// Created by Stanislav on 24.04.2024.
|
// Created by Avently on 24.04.2024.
|
||||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct IncomingCallView: View {
|
struct IncomingCallView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var cc = CallController.shared
|
@ObservedObject var cc = CallController.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -43,7 +44,7 @@ struct IncomingCallView: View {
|
|||||||
cc.endCall(invitation: invitation)
|
cc.endCall(invitation: invitation)
|
||||||
}
|
}
|
||||||
|
|
||||||
callButton("Ignore", "multiply", .accentColor) {
|
callButton("Ignore", "multiply", .primary) {
|
||||||
cc.activeCallInvitation = nil
|
cc.activeCallInvitation = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ struct IncomingCallView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.modifier(ThemedBackground())
|
||||||
.onAppear { dismissAllSheets() }
|
.onAppear { dismissAllSheets() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ struct IncomingCallView: View {
|
|||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 44)
|
.frame(minWidth: 44)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
|
||||||
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
|
|
||||||
struct ChatInfoToolbar: View {
|
struct ChatInfoToolbar: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var imageSize: CGFloat = 32
|
var imageSize: CGFloat = 32
|
||||||
|
|
||||||
@@ -26,9 +25,7 @@ struct ChatInfoToolbar: View {
|
|||||||
ChatInfoImage(
|
ChatInfoImage(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
size: imageSize,
|
size: imageSize,
|
||||||
color: colorScheme == .dark
|
color: Color(uiColor: .tertiaryLabel)
|
||||||
? chatImageColorDark
|
|
||||||
: chatImageColorLight
|
|
||||||
)
|
)
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
VStack {
|
VStack {
|
||||||
@@ -41,14 +38,14 @@ struct ChatInfoToolbar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.frame(width: 220)
|
.frame(width: 220)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contactVerifiedShield: Text {
|
private var contactVerifiedShield: Text {
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.baselineOffset(1)
|
.baselineOffset(1)
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
}
|
}
|
||||||
@@ -57,5 +54,6 @@ struct ChatInfoToolbar: View {
|
|||||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||||
|
.environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View {
|
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String], _ secondaryColor: Color) -> some View {
|
||||||
if servers.count > 0 {
|
if servers.count > 0 {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title).frame(width: 120, alignment: .leading)
|
Text(title).frame(width: 120, alignment: .leading)
|
||||||
Button(serverHost(servers[0])) {
|
Button(serverHost(servers[0])) {
|
||||||
UIPasteboard.general.string = servers.joined(separator: ";")
|
UIPasteboard.general.string = servers.joined(separator: ";")
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,7 @@ enum SendReceipts: Identifiable, Hashable {
|
|||||||
|
|
||||||
struct ChatInfoView: View {
|
struct ChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State var contact: Contact
|
@State var contact: Contact
|
||||||
@@ -110,6 +111,7 @@ struct ChatInfoView: View {
|
|||||||
case switchAddressAlert
|
case switchAddressAlert
|
||||||
case abortSwitchAddressAlert
|
case abortSwitchAddressAlert
|
||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
|
case queueInfo(info: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
@@ -119,6 +121,7 @@ struct ChatInfoView: View {
|
|||||||
case .switchAddressAlert: return "switchAddressAlert"
|
case .switchAddressAlert: return "switchAddressAlert"
|
||||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +144,7 @@ struct ChatInfoView: View {
|
|||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
if let customUserProfile = customUserProfile {
|
if let customUserProfile = customUserProfile {
|
||||||
Section("Incognito") {
|
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Your random profile")
|
Text("Your random profile")
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -159,6 +162,11 @@ struct ChatInfoView: View {
|
|||||||
connStats.ratchetSyncAllowed {
|
connStats.ratchetSyncAllowed {
|
||||||
synchronizeConnectionButton()
|
synchronizeConnectionButton()
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ChatWallpaperEditorSheet(chat: chat)
|
||||||
|
} label: {
|
||||||
|
Label("Chat theme", systemImage: "photo")
|
||||||
|
}
|
||||||
// } else if developerTools {
|
// } else if developerTools {
|
||||||
// synchronizeConnectionButtonForce()
|
// synchronizeConnectionButtonForce()
|
||||||
// }
|
// }
|
||||||
@@ -181,13 +189,15 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Address")
|
Text("Address")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if contact.ready && contact.active {
|
if contact.ready && contact.active {
|
||||||
Section("Servers") {
|
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||||
networkStatusRow()
|
networkStatusRow()
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
alert = .networkStatusAlert
|
alert = .networkStatusAlert
|
||||||
@@ -209,8 +219,8 @@ struct ChatInfoView: View {
|
|||||||
|| connStats.ratchetSyncSendProhibited
|
|| connStats.ratchetSyncSendProhibited
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,12 +231,25 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section(header: Text("For console")) {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||||
|
Button ("Debug delivery") {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||||
|
await MainActor.run { alert = .queueInfo(info: info) }
|
||||||
|
} catch let e {
|
||||||
|
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||||
|
let a = getErrorAlert(e, "Error")
|
||||||
|
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
@@ -243,6 +266,7 @@ struct ChatInfoView: View {
|
|||||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||||
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +301,7 @@ struct ChatInfoView: View {
|
|||||||
if contact.verified {
|
if contact.verified {
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "checkmark.shield"))
|
Text(Image(systemName: "checkmark.shield"))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text(contact.profile.displayName)
|
+ Text(contact.profile.displayName)
|
||||||
@@ -317,7 +341,7 @@ struct ChatInfoView: View {
|
|||||||
setContactAlias()
|
setContactAlias()
|
||||||
}
|
}
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setContactAlias() {
|
private func setContactAlias() {
|
||||||
@@ -355,6 +379,7 @@ struct ChatInfoView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("Security code")
|
.navigationTitle("Security code")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
contact.verified ? "View security code" : "Verify security code",
|
contact.verified ? "View security code" : "Verify security code",
|
||||||
@@ -371,6 +396,7 @@ struct ChatInfoView: View {
|
|||||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Contact preferences")
|
.navigationBarTitle("Contact preferences")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Contact preferences", systemImage: "switch.2")
|
Label("Contact preferences", systemImage: "switch.2")
|
||||||
@@ -419,11 +445,11 @@ struct ChatInfoView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Network status")
|
Text("Network status")
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(chatModel.contactNetworkStatus(contact).statusString)
|
Text(chatModel.contactNetworkStatus(contact).statusString)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
serverImage()
|
serverImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,7 +457,7 @@ struct ChatInfoView: View {
|
|||||||
private func serverImage() -> some View {
|
private func serverImage() -> some View {
|
||||||
let status = chatModel.contactNetworkStatus(contact)
|
let status = chatModel.contactNetworkStatus(contact)
|
||||||
return Image(systemName: status.imageName)
|
return Image(systemName: status.imageName)
|
||||||
.foregroundColor(status == .connected ? .green : .secondary)
|
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +576,148 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChatWallpaperEditorSheet: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@State private var globalThemeUsed: Bool = false
|
||||||
|
@State var chat: Chat
|
||||||
|
@State private var themes: ThemeModeOverrides
|
||||||
|
|
||||||
|
init(chat: Chat) {
|
||||||
|
self.chat = chat
|
||||||
|
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else {
|
||||||
|
ThemeModeOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let preferred = themes.preferredMode(!theme.colors.isLight)
|
||||||
|
let initialTheme = preferred ?? ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
ChatWallpaperEditor(
|
||||||
|
initialTheme: initialTheme,
|
||||||
|
themeModeOverride: initialTheme,
|
||||||
|
applyToMode: themes.light == themes.dark ? nil : initialTheme.mode,
|
||||||
|
globalThemeUsed: $globalThemeUsed,
|
||||||
|
save: { applyToMode, newTheme in
|
||||||
|
await save(applyToMode, newTheme, $chat)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.navigationTitle("Chat theme")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
globalThemeUsed = preferred == nil
|
||||||
|
}
|
||||||
|
.onChange(of: theme.base.mode) { _ in
|
||||||
|
globalThemeUsed = themesFromChat(chat).preferredMode(!theme.colors.isLight) == nil
|
||||||
|
}
|
||||||
|
.onChange(of: ChatModel.shared.chatId) { _ in
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
|
||||||
|
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else {
|
||||||
|
ThemeModeOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var updateBackendTask: Task = Task {}
|
||||||
|
private func save(
|
||||||
|
_ applyToMode: DefaultThemeMode?,
|
||||||
|
_ newTheme: ThemeModeOverride?,
|
||||||
|
_ chat: Binding<Chat>
|
||||||
|
) async {
|
||||||
|
let unchangedThemes: ThemeModeOverrides = themesFromChat(chat.wrappedValue)
|
||||||
|
var wallpaperFiles = Set([unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile])
|
||||||
|
var changedThemes: ThemeModeOverrides? = unchangedThemes
|
||||||
|
let light: ThemeModeOverride? = if let newTheme {
|
||||||
|
ThemeModeOverride(mode: DefaultThemeMode.light, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let dark: ThemeModeOverride? = if let newTheme {
|
||||||
|
ThemeModeOverride(mode: DefaultThemeMode.dark, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let applyToMode {
|
||||||
|
switch applyToMode {
|
||||||
|
case DefaultThemeMode.light:
|
||||||
|
changedThemes?.light = light
|
||||||
|
case DefaultThemeMode.dark:
|
||||||
|
changedThemes?.dark = dark
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changedThemes?.light = light
|
||||||
|
changedThemes?.dark = dark
|
||||||
|
}
|
||||||
|
if changedThemes?.light != nil || changedThemes?.dark != nil {
|
||||||
|
let light = changedThemes?.light
|
||||||
|
let dark = changedThemes?.dark
|
||||||
|
let currentMode = CurrentColors.base.mode
|
||||||
|
// same image file for both modes, copy image to make them as different files
|
||||||
|
if var light, var dark, let lightWallpaper = light.wallpaper, let darkWallpaper = dark.wallpaper, let lightImageFile = lightWallpaper.imageFile, let darkImageFile = darkWallpaper.imageFile, lightWallpaper.imageFile == darkWallpaper.imageFile {
|
||||||
|
let imageFile = if currentMode == DefaultThemeMode.light {
|
||||||
|
darkImageFile
|
||||||
|
} else {
|
||||||
|
lightImageFile
|
||||||
|
}
|
||||||
|
let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile))
|
||||||
|
if currentMode == DefaultThemeMode.light {
|
||||||
|
dark.wallpaper?.imageFile = filePath
|
||||||
|
changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark)
|
||||||
|
} else {
|
||||||
|
light.wallpaper?.imageFile = filePath
|
||||||
|
changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changedThemes = nil
|
||||||
|
}
|
||||||
|
wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile)
|
||||||
|
wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile)
|
||||||
|
wallpaperFiles.forEach(removeWallpaperFile)
|
||||||
|
|
||||||
|
let changedThemesConstant = changedThemes
|
||||||
|
ChatWallpaperEditorSheet.updateBackendTask.cancel()
|
||||||
|
ChatWallpaperEditorSheet.updateBackendTask = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 300_000000)
|
||||||
|
if await apiSetChatUIThemes(chatId: chat.id, themes: changedThemesConstant) {
|
||||||
|
if case var ChatInfo.direct(contact) = chat.wrappedValue.chatInfo {
|
||||||
|
contact.uiThemes = changedThemesConstant
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.updateChatInfo(ChatInfo.direct(contact: contact))
|
||||||
|
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
|
||||||
|
themes = themesFromChat(chat.wrappedValue)
|
||||||
|
}
|
||||||
|
} else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
|
||||||
|
groupInfo.uiThemes = changedThemesConstant
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
|
||||||
|
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
|
||||||
|
themes = themesFromChat(chat.wrappedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// canceled task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Change receiving address?"),
|
title: Text("Change receiving address?"),
|
||||||
@@ -577,6 +745,22 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
|
||||||
|
let (rcvMsgInfo, qInfo) = info
|
||||||
|
var msgInfo: String
|
||||||
|
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
|
||||||
|
return String.localizedStringWithFormat(NSLocalizedString("server queue info: %@\n\nlast received msg: %@", comment: "queue info"), encodeJSON(qInfo), msgInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queueInfoAlert(_ info: String) -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Message queue info"),
|
||||||
|
message: Text(info),
|
||||||
|
primaryButton: .default(Text("Ok")),
|
||||||
|
secondaryButton: .default(Text("Copy")) { UIPasteboard.general.string = info }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatInfoView_Previews: PreviewProvider {
|
struct ChatInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoView(
|
ChatInfoView(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CICallItemView: View {
|
struct CICallItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var status: CICallStatus
|
var status: CICallStatus
|
||||||
@@ -22,7 +23,7 @@ struct CICallItemView: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case .pending:
|
case .pending:
|
||||||
if sent {
|
if sent {
|
||||||
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
acceptCallButton()
|
acceptCallButton()
|
||||||
}
|
}
|
||||||
@@ -35,7 +36,7 @@ struct CICallItemView: View {
|
|||||||
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
|
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ struct CICallItemView: View {
|
|||||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "phone.down")
|
Image(systemName: "phone.down")
|
||||||
Text(durationText(duration)).foregroundColor(.secondary)
|
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ struct CICallItemView: View {
|
|||||||
Label("Answer call", systemImage: "phone.arrow.down.left")
|
Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
|||||||
struct CIChatFeatureView: View {
|
struct CIChatFeatureView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
var feature: Feature
|
var feature: Feature
|
||||||
@@ -66,10 +67,10 @@ struct CIChatFeatureView: View {
|
|||||||
|
|
||||||
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
||||||
switch ci.content {
|
switch ci.content {
|
||||||
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
|
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
|
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
default: nil
|
default: nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +82,7 @@ struct CIChatFeatureView: View {
|
|||||||
if let param = f.param {
|
if let param = f.param {
|
||||||
HStack {
|
HStack {
|
||||||
i
|
i
|
||||||
chatEventText(Text(param)).lineLimit(1)
|
chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
i
|
i
|
||||||
@@ -93,7 +94,7 @@ struct CIChatFeatureView: View {
|
|||||||
Image(systemName: icon ?? feature.iconFilled)
|
Image(systemName: icon ?? feature.iconFilled)
|
||||||
.foregroundColor(iconColor)
|
.foregroundColor(iconColor)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
chatEventText(chatItem)
|
chatEventText(chatItem, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -104,6 +105,6 @@ struct CIChatFeatureView: View {
|
|||||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||||
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFeaturePreferenceView: View {
|
struct CIFeaturePreferenceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var feature: ChatFeature
|
var feature: ChatFeature
|
||||||
var allowed: FeatureAllowed
|
var allowed: FeatureAllowed
|
||||||
@@ -19,7 +20,7 @@ struct CIFeaturePreferenceView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
Image(systemName: feature.icon)
|
Image(systemName: feature.icon)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
if let ct = chat.chatInfo.contact,
|
if let ct = chat.chatInfo.contact,
|
||||||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||||
@@ -40,17 +41,17 @@ struct CIFeaturePreferenceView: View {
|
|||||||
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
||||||
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
if let acceptText {
|
if let acceptText {
|
||||||
r = r
|
r = r
|
||||||
+ Text(acceptText)
|
+ Text(acceptText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFileView: View {
|
struct CIFileView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let file: CIFile?
|
let file: CIFile?
|
||||||
let edited: Bool
|
let edited: Bool
|
||||||
|
|
||||||
@@ -30,12 +30,12 @@ struct CIFileView: View {
|
|||||||
Text(file.fileName)
|
Text(file.fileName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
Text(prettyFileSize + metaReserve)
|
Text(prettyFileSize + metaReserve)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(metaReserve)
|
Text(metaReserve)
|
||||||
@@ -54,16 +54,18 @@ struct CIFileView: View {
|
|||||||
switch (file.fileStatus) {
|
switch (file.fileStatus) {
|
||||||
case .sndStored: return file.fileProtocol == .local
|
case .sndStored: return file.fileProtocol == .local
|
||||||
case .sndTransfer: return false
|
case .sndTransfer: return false
|
||||||
case .sndComplete: return false
|
case .sndComplete: return true
|
||||||
case .sndCancelled: return false
|
case .sndCancelled: return false
|
||||||
case .sndError: return false
|
case .sndError: return true
|
||||||
|
case .sndWarning: return true
|
||||||
case .rcvInvitation: return true
|
case .rcvInvitation: return true
|
||||||
case .rcvAccepted: return true
|
case .rcvAccepted: return true
|
||||||
case .rcvTransfer: return false
|
case .rcvTransfer: return false
|
||||||
case .rcvAborted: return true
|
case .rcvAborted: return true
|
||||||
case .rcvComplete: return true
|
case .rcvComplete: return true
|
||||||
case .rcvCancelled: return false
|
case .rcvCancelled: return false
|
||||||
case .rcvError: return false
|
case .rcvError: return true
|
||||||
|
case .rcvWarning: return true
|
||||||
case .invalid: return false
|
case .invalid: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,11 +110,40 @@ struct CIFileView: View {
|
|||||||
if let fileSource = getLoadedFileSource(file) {
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
saveCryptoFile(fileSource)
|
saveCryptoFile(fileSource)
|
||||||
}
|
}
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
case .sndStored:
|
case .sndStored:
|
||||||
logger.debug("CIFileView fileAction - in .sndStored")
|
logger.debug("CIFileView fileAction - in .sndStored")
|
||||||
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
||||||
saveCryptoFile(fileSource)
|
saveCryptoFile(fileSource)
|
||||||
}
|
}
|
||||||
|
case .sndComplete:
|
||||||
|
logger.debug("CIFileView fileAction - in .sndComplete")
|
||||||
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
|
saveCryptoFile(fileSource)
|
||||||
|
}
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,9 +167,10 @@ struct CIFileView: View {
|
|||||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .rcvInvitation:
|
case .rcvInvitation:
|
||||||
if fileSizeValid(file) {
|
if fileSizeValid(file) {
|
||||||
fileIcon("arrow.down.doc.fill", color: .accentColor)
|
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
|
||||||
} else {
|
} else {
|
||||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||||
}
|
}
|
||||||
@@ -150,10 +182,11 @@ struct CIFileView: View {
|
|||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
case .rcvAborted:
|
case .rcvAborted:
|
||||||
fileIcon("doc.fill", color: .accentColor, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
|
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
|
||||||
case .rcvComplete: fileIcon("doc.fill")
|
case .rcvComplete: fileIcon("doc.fill")
|
||||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIGroupInvitationView: View {
|
struct CIGroupInvitationView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var groupInvitation: CIGroupInvitation
|
var groupInvitation: CIGroupInvitation
|
||||||
@@ -42,7 +42,7 @@ struct CIGroupInvitationView: View {
|
|||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
(
|
(
|
||||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||||
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
|
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
||||||
@@ -65,12 +65,11 @@ struct CIGroupInvitationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||||
.onChange(of: inProgress) { inProgress in
|
.onChange(of: inProgress) { inProgress in
|
||||||
@@ -99,7 +98,7 @@ struct CIGroupInvitationView: View {
|
|||||||
private func groupInfoView(_ action: Bool) -> some View {
|
private func groupInfoView(_ action: Bool) -> some View {
|
||||||
var color: Color
|
var color: Color
|
||||||
if action && !inProgress {
|
if action && !inProgress {
|
||||||
color = chatIncognito ? .indigo : .accentColor
|
color = chatIncognito ? .indigo : theme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
color = Color(uiColor: .tertiaryLabel)
|
color = Color(uiColor: .tertiaryLabel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIImageView: View {
|
struct CIImageView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
let chatItem: ChatItem
|
let chatItem: ChatItem
|
||||||
let image: String
|
var preview: UIImage?
|
||||||
let maxWidth: CGFloat
|
let maxWidth: CGFloat
|
||||||
@Binding var imgWidth: CGFloat?
|
var imgWidth: CGFloat?
|
||||||
@State var scrollProxy: ScrollViewProxy?
|
|
||||||
@State private var showFullScreenImage = false
|
@State private var showFullScreenImage = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -25,15 +23,14 @@ struct CIImageView: View {
|
|||||||
if let uiImage = getLoadedImage(file) {
|
if let uiImage = getLoadedImage(file) {
|
||||||
imageView(uiImage)
|
imageView(uiImage)
|
||||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||||
}
|
}
|
||||||
.onTapGesture { showFullScreenImage = true }
|
.onTapGesture { showFullScreenImage = true }
|
||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenImage = false
|
showFullScreenImage = false
|
||||||
}
|
}
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let preview {
|
||||||
let uiImage = UIImage(data: data) {
|
imageView(preview)
|
||||||
imageView(uiImage)
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
@@ -60,6 +57,26 @@ struct CIImageView: View {
|
|||||||
case .rcvTransfer: () // ?
|
case .rcvTransfer: () // ?
|
||||||
case .rcvComplete: () // ?
|
case .rcvComplete: () // ?
|
||||||
case .rcvCancelled: () // TODO
|
case .rcvCancelled: () // TODO
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +87,6 @@ struct CIImageView: View {
|
|||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { imgWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
if img.imageData == nil {
|
if img.imageData == nil {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
@@ -99,14 +115,16 @@ struct CIImageView: View {
|
|||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case .sndError: fileIcon("xmark", 10, 13)
|
||||||
|
case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case .rcvTransfer: progressView()
|
case .rcvTransfer: progressView()
|
||||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case .rcvError: fileIcon("xmark", 10, 13)
|
||||||
|
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CIInvalidJSONView: View {
|
struct CIInvalidJSONView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var json: String
|
var json: String
|
||||||
@State private var showJSON = false
|
@State private var showJSON = false
|
||||||
|
|
||||||
@@ -21,10 +22,9 @@ struct CIInvalidJSONView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture { showJSON = true }
|
.onTapGesture { showJSON = true }
|
||||||
.sheet(isPresented: $showJSON) {
|
.appSheet(isPresented: $showJSON) {
|
||||||
invalidJSONView(json)
|
invalidJSONView(json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,7 @@ func invalidJSONView(_ json: String) -> some View {
|
|||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIInvalidJSONView_Previews: PreviewProvider {
|
struct CIInvalidJSONView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct CILinkView: View {
|
struct CILinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview
|
let linkPreview: LinkPreview
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -32,7 +32,7 @@ struct CILinkView: View {
|
|||||||
Text(linkPreview.uri.absoluteString)
|
Text(linkPreview.uri.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMemberCreatedContactView: View {
|
struct CIMemberCreatedContactView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -43,12 +44,12 @@ struct CIMemberCreatedContactView: View {
|
|||||||
r = r
|
r = r
|
||||||
+ Text(openText)
|
+ Text(openText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +57,11 @@ struct CIMemberCreatedContactView: View {
|
|||||||
if let member = chatItem.memberDisplayName {
|
if let member = chatItem.memberDisplayName {
|
||||||
return Text(member + " " + chatItem.content.text + " ")
|
return Text(member + " " + chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
return Text(chatItem.content.text + " ")
|
return Text(chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMetaView: View {
|
struct CIMetaView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var metaColor = Color.secondary
|
var metaColor: Color
|
||||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||||
var showStatus = true
|
var showStatus = true
|
||||||
var showEdited = true
|
var showEdited = true
|
||||||
@@ -63,6 +64,7 @@ func ciMetaText(
|
|||||||
chatTTL: Int?,
|
chatTTL: Int?,
|
||||||
encrypted: Bool?,
|
encrypted: Bool?,
|
||||||
color: Color = .clear,
|
color: Color = .clear,
|
||||||
|
primaryColor: Color = .accentColor,
|
||||||
transparent: Bool = false,
|
transparent: Bool = false,
|
||||||
sent: SentCheckmark? = nil,
|
sent: SentCheckmark? = nil,
|
||||||
showStatus: Bool = true,
|
showStatus: Bool = true,
|
||||||
@@ -85,7 +87,7 @@ func ciMetaText(
|
|||||||
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
||||||
}
|
}
|
||||||
if showStatus {
|
if showStatus {
|
||||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
|
||||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
let t = Text(Image(systemName: icon)).font(.caption2)
|
||||||
let gap = Text(" ").kerning(-1.25)
|
let gap = Text(" ").kerning(-1.25)
|
||||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||||
@@ -112,15 +114,16 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct CIMetaView_Previews: PreviewProvider {
|
struct CIMetaView_Previews: PreviewProvider {
|
||||||
|
static let metaColor = Color.secondary
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 100))
|
.previewLayout(.fixed(width: 360, height: 100))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
|
|||||||
|
|
||||||
struct CIRcvDecryptionError: View {
|
struct CIRcvDecryptionError: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var msgDecryptError: MsgDecryptError
|
var msgDecryptError: MsgDecryptError
|
||||||
var msgCount: UInt32
|
var msgCount: UInt32
|
||||||
@@ -114,24 +115,23 @@ struct CIRcvDecryptionError: View {
|
|||||||
}
|
}
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text("Fix connection")
|
+ Text("Fix connection")
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,13 +145,12 @@ struct CIRcvDecryptionError: View {
|
|||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,13 @@ import Combine
|
|||||||
|
|
||||||
struct CIVideoView: View {
|
struct CIVideoView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
private let chatItem: ChatItem
|
private let chatItem: ChatItem
|
||||||
private let image: String
|
private let preview: UIImage?
|
||||||
@State private var duration: Int
|
@State private var duration: Int
|
||||||
@State private var progress: Int = 0
|
@State private var progress: Int = 0
|
||||||
@State private var videoPlaying: Bool = false
|
@State private var videoPlaying: Bool = false
|
||||||
private let maxWidth: CGFloat
|
private let maxWidth: CGFloat
|
||||||
@Binding private var videoWidth: CGFloat?
|
private var videoWidth: CGFloat?
|
||||||
@State private var scrollProxy: ScrollViewProxy?
|
|
||||||
@State private var preview: UIImage? = nil
|
|
||||||
@State private var player: AVPlayer?
|
@State private var player: AVPlayer?
|
||||||
@State private var fullPlayer: AVPlayer?
|
@State private var fullPlayer: AVPlayer?
|
||||||
@State private var url: URL?
|
@State private var url: URL?
|
||||||
@@ -33,13 +30,12 @@ struct CIVideoView: View {
|
|||||||
@State private var fullScreenTimeObserver: Any? = nil
|
@State private var fullScreenTimeObserver: Any? = nil
|
||||||
@State private var publisher: AnyCancellable? = nil
|
@State private var publisher: AnyCancellable? = nil
|
||||||
|
|
||||||
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?) {
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.image = image
|
self.preview = preview
|
||||||
self._duration = State(initialValue: duration)
|
self._duration = State(initialValue: duration)
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self._videoWidth = videoWidth
|
self.videoWidth = videoWidth
|
||||||
self.scrollProxy = scrollProxy
|
|
||||||
if let url = getLoadedVideo(chatItem.file) {
|
if let url = getLoadedVideo(chatItem.file) {
|
||||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
||||||
self._urlDecrypted = State(initialValue: decrypted)
|
self._urlDecrypted = State(initialValue: decrypted)
|
||||||
@@ -49,10 +45,6 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
self._url = State(initialValue: url)
|
self._url = State(initialValue: url)
|
||||||
}
|
}
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
self._preview = State(initialValue: uiImage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -63,9 +55,8 @@ struct CIVideoView: View {
|
|||||||
videoView(player, decrypted, file, preview, duration)
|
videoView(player, decrypted, file, preview, duration)
|
||||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||||
videoViewEncrypted(file, defaultPreview, duration)
|
videoViewEncrypted(file, defaultPreview, duration)
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let preview {
|
||||||
let uiImage = UIImage(data: data) {
|
imageView(preview)
|
||||||
imageView(uiImage)
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
@@ -152,7 +143,6 @@ struct CIVideoView: View {
|
|||||||
|
|
||||||
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
||||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
@@ -192,7 +182,7 @@ struct CIVideoView: View {
|
|||||||
.disabled(!canBePlayed)
|
.disabled(!canBePlayed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
fileStatusIcon()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
addObserver(player, url)
|
addObserver(player, url)
|
||||||
@@ -252,17 +242,16 @@ struct CIVideoView: View {
|
|||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: w)
|
.frame(width: w)
|
||||||
loadingIndicator()
|
fileStatusIcon()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func loadingIndicator() -> some View {
|
@ViewBuilder private func fileStatusIcon() -> some View {
|
||||||
if let file = chatItem.file {
|
if let file = chatItem.file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .sndStored:
|
case .sndStored:
|
||||||
@@ -279,7 +268,22 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case let .sndError(sndFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||||
@@ -289,10 +293,25 @@ struct CIVideoView: View {
|
|||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case let .rcvError(rcvFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIVoiceView: View {
|
struct CIVoiceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
@@ -72,7 +73,7 @@ struct CIVoiceView: View {
|
|||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime
|
playbackTime: $playbackTime
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playbackSlider() -> some View {
|
private func playbackSlider() -> some View {
|
||||||
@@ -89,10 +90,11 @@ struct CIVoiceView: View {
|
|||||||
allowMenu = true
|
allowMenu = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func metaView() -> some View {
|
private func metaView() -> some View {
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ struct VoiceMessagePlayerTime: View {
|
|||||||
|
|
||||||
struct VoiceMessagePlayer: View {
|
struct VoiceMessagePlayer: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var recordingFile: CIFile?
|
var recordingFile: CIFile?
|
||||||
var recordingTime: TimeInterval
|
var recordingTime: TimeInterval
|
||||||
@@ -134,18 +136,53 @@ struct VoiceMessagePlayer: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
if let recordingFile = recordingFile {
|
if let recordingFile = recordingFile {
|
||||||
switch recordingFile.fileStatus {
|
switch recordingFile.fileStatus {
|
||||||
case .sndStored: playbackButton()
|
case .sndStored:
|
||||||
case .sndTransfer: playbackButton()
|
if recordingFile.fileProtocol == .local {
|
||||||
|
playbackButton()
|
||||||
|
} else {
|
||||||
|
loadingIcon()
|
||||||
|
}
|
||||||
|
case .sndTransfer: loadingIcon()
|
||||||
case .sndComplete: playbackButton()
|
case .sndComplete: playbackButton()
|
||||||
case .sndCancelled: playbackButton()
|
case .sndCancelled: playbackButton()
|
||||||
case .sndError: playbackButton()
|
case let .sndError(sndFileError):
|
||||||
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
||||||
case .rcvAccepted: loadingIcon()
|
case .rcvAccepted: loadingIcon()
|
||||||
case .rcvTransfer: loadingIcon()
|
case .rcvTransfer: loadingIcon()
|
||||||
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
||||||
case .rcvComplete: playbackButton()
|
case .rcvComplete: playbackButton()
|
||||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case let .rcvError(rcvFileError):
|
||||||
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -181,26 +218,26 @@ struct VoiceMessagePlayer: View {
|
|||||||
startPlayback(recordingSource)
|
startPlayback(recordingSource)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
case .playing:
|
case .playing:
|
||||||
Button {
|
Button {
|
||||||
audioPlayer?.pause()
|
audioPlayer?.pause()
|
||||||
playbackState = .paused
|
playbackState = .paused
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("pause.fill")
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
case .paused:
|
case .paused:
|
||||||
Button {
|
Button {
|
||||||
audioPlayer?.play()
|
audioPlayer?.play()
|
||||||
playbackState = .playing
|
playbackState = .playing
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -209,7 +246,7 @@ struct VoiceMessagePlayer: View {
|
|||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
if recordingTime > 0 {
|
if recordingTime > 0 {
|
||||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||||
@@ -226,11 +263,12 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon(icon)
|
playPauseIcon(icon, theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProgressCircle: View {
|
private struct ProgressCircle: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
@@ -238,7 +276,7 @@ struct VoiceMessagePlayer: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
||||||
.stroke(
|
.stroke(
|
||||||
Color.accentColor,
|
theme.colors.primary,
|
||||||
style: StrokeStyle(lineWidth: 3)
|
style: StrokeStyle(lineWidth: 3)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
@@ -246,11 +284,22 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
private func loadingIcon() -> some View {
|
private func loadingIcon() -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,22 +10,21 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct DeletedItemView: View {
|
struct DeletedItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .bottom, spacing: 0) {
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
Text(chatItem.content.text)
|
Text(chatItem.content.text)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.italic()
|
.italic()
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct EmojiItemView: View {
|
struct EmojiItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -18,7 +19,7 @@ struct EmojiItemView: View {
|
|||||||
emojiText(chatItem.content.text)
|
emojiText(chatItem.content.text)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct FramedCIVoiceView: View {
|
struct FramedCIVoiceView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
@@ -42,7 +43,7 @@ struct FramedCIVoiceView: View {
|
|||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime
|
playbackTime: $playbackTime
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(width: 50, alignment: .leading)
|
.frame(width: 50, alignment: .leading)
|
||||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
playbackSlider()
|
playbackSlider()
|
||||||
|
|||||||
@@ -9,25 +9,19 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let notesChatColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.21)
|
|
||||||
let notesChatColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.19)
|
|
||||||
let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
|
|
||||||
let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
|
|
||||||
private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11)
|
|
||||||
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
|
|
||||||
|
|
||||||
struct FramedItemView: View {
|
struct FramedItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
var preview: UIImage?
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
var maxWidth: CGFloat = .infinity
|
var maxWidth: CGFloat = .infinity
|
||||||
@State var scrollProxy: ScrollViewProxy? = nil
|
|
||||||
@State var msgWidth: CGFloat = 0
|
@State var msgWidth: CGFloat = 0
|
||||||
@State var imgWidth: CGFloat? = nil
|
var imgWidth: CGFloat? = nil
|
||||||
@State var videoWidth: CGFloat? = nil
|
var videoWidth: CGFloat? = nil
|
||||||
@State var metaColor = Color.secondary
|
@State private var useWhiteMetaColor: Bool = false
|
||||||
@State var showFullScreenImage = false
|
@State var showFullScreenImage = false
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
@State private var showSecrets = false
|
@State private var showSecrets = false
|
||||||
@@ -58,10 +52,9 @@ struct FramedItemView: View {
|
|||||||
if let qi = chatItem.quotedItem {
|
if let qi = chatItem.quotedItem {
|
||||||
ciQuoteView(qi)
|
ciQuoteView(qi)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let proxy = scrollProxy,
|
if let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||||
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(ci.viewId, anchor: .bottom)
|
scrollModel.scrollToItem(id: ci.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,18 +66,16 @@ struct FramedItemView: View {
|
|||||||
.padding(chatItem.content.msgContent != nil ? 0 : 4)
|
.padding(chatItem.content.msgContent != nil ? 0 : 4)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
.onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 }
|
|
||||||
|
|
||||||
if chatItem.content.msgContent != nil {
|
if chatItem.content.msgContent != nil {
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: metaColor)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
.accessibilityLabel("")
|
.accessibilityLabel("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
|
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
||||||
|
|
||||||
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
|
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
|
||||||
@@ -114,29 +105,33 @@ struct FramedItemView: View {
|
|||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
} else {
|
} else {
|
||||||
switch (chatItem.content.msgContent) {
|
switch (chatItem.content.msgContent) {
|
||||||
case let .image(text, image):
|
case let .image(text, _):
|
||||||
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
|
CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
if text == "" && !chatItem.meta.isLive {
|
if text == "" && !chatItem.meta.isLive {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: 0, height: 0)
|
.frame(width: 0, height: 0)
|
||||||
.preference(
|
.onAppear {
|
||||||
key: MetaColorPreferenceKey.self,
|
useWhiteMetaColor = true
|
||||||
value: .white
|
}
|
||||||
)
|
.onDisappear {
|
||||||
|
useWhiteMetaColor = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ciMsgContentView(chatItem)
|
ciMsgContentView(chatItem)
|
||||||
}
|
}
|
||||||
case let .video(text, image, duration):
|
case let .video(text, _, duration):
|
||||||
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
|
CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
if text == "" && !chatItem.meta.isLive {
|
if text == "" && !chatItem.meta.isLive {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: 0, height: 0)
|
.frame(width: 0, height: 0)
|
||||||
.preference(
|
.onAppear {
|
||||||
key: MetaColorPreferenceKey.self,
|
useWhiteMetaColor = true
|
||||||
value: .white
|
}
|
||||||
)
|
.onDisappear {
|
||||||
|
useWhiteMetaColor = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ciMsgContentView(chatItem)
|
ciMsgContentView(chatItem)
|
||||||
}
|
}
|
||||||
@@ -175,13 +170,13 @@ struct FramedItemView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
.padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0)
|
.padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
.frame(minWidth: msgWidth, alignment: .leading)
|
.frame(minWidth: msgWidth, alignment: .leading)
|
||||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
.background(chatItemFrameContextColor(chatItem, theme))
|
||||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
@@ -233,7 +228,7 @@ struct FramedItemView: View {
|
|||||||
// if enable this always, size of the framed voice message item will be incorrect after end of playback
|
// if enable this always, size of the framed voice message item will be incorrect after end of playback
|
||||||
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
|
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
|
||||||
.frame(minWidth: msgWidth, alignment: .leading)
|
.frame(minWidth: msgWidth, alignment: .leading)
|
||||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
.background(chatItemFrameContextColor(chatItem, theme))
|
||||||
|
|
||||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||||
@@ -248,7 +243,7 @@ struct FramedItemView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(sender)
|
Text(sender)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
ciQuotedMsgTextView(qi, lines: 2)
|
ciQuotedMsgTextView(qi, lines: 2)
|
||||||
}
|
}
|
||||||
@@ -347,13 +342,6 @@ func isRightToLeft(_ s: String) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MetaColorPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue = Color.secondary
|
|
||||||
static func reduce(value: inout Color, nextValue: () -> Color) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
|
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
|
||||||
if case let .image(text, _) = ci.content.msgContent {
|
if case let .image(text, _) = ci.content.msgContent {
|
||||||
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == ""
|
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == ""
|
||||||
@@ -363,22 +351,22 @@ func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ theme: AppTheme) -> Color {
|
||||||
onlyImageOrVideo(ci)
|
onlyImageOrVideo(ci)
|
||||||
? Color.clear
|
? Color.clear
|
||||||
: chatItemFrameColor(ci, colorScheme)
|
: chatItemFrameColor(ci, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
func chatItemFrameColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
|
||||||
ci.chatDir.sent
|
ci.chatDir.sent
|
||||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
? theme.appColors.sentMessage
|
||||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
: theme.appColors.receivedMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
|
||||||
ci.chatDir.sent
|
ci.chatDir.sent
|
||||||
? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark)
|
? theme.appColors.sentQuote
|
||||||
: Color(uiColor: .quaternarySystemFill)
|
: theme.appColors.receivedQuote
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FramedItemView_Previews: PreviewProvider {
|
struct FramedItemView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import AVKit
|
|||||||
|
|
||||||
struct FullScreenMediaView: View {
|
struct FullScreenMediaView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
|
||||||
@State var chatItem: ChatItem
|
@State var chatItem: ChatItem
|
||||||
@State var image: UIImage?
|
@State var image: UIImage?
|
||||||
@State var player: AVPlayer? = nil
|
@State var player: AVPlayer? = nil
|
||||||
@State var url: URL? = nil
|
@State var url: URL? = nil
|
||||||
@Binding var showView: Bool
|
@Binding var showView: Bool
|
||||||
@State var scrollProxy: ScrollViewProxy?
|
|
||||||
@State private var showNext = false
|
@State private var showNext = false
|
||||||
@State private var nextImage: UIImage?
|
@State private var nextImage: UIImage?
|
||||||
@State private var nextPlayer: AVPlayer?
|
@State private var nextPlayer: AVPlayer?
|
||||||
@@ -71,9 +71,7 @@ struct FullScreenMediaView: View {
|
|||||||
let w = abs(t.width)
|
let w = abs(t.width)
|
||||||
if t.height > 60 && t.height > w * 2 {
|
if t.height > 60 && t.height > w * 2 {
|
||||||
showView = false
|
showView = false
|
||||||
if let proxy = scrollProxy {
|
scrollModel.scrollToItem(id: chatItem.id)
|
||||||
proxy.scrollTo(chatItem.viewId)
|
|
||||||
}
|
|
||||||
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
||||||
let previous = t.width > 0
|
let previous = t.width > 0
|
||||||
scrolling = true
|
scrolling = true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct IntegrityErrorItemView: View {
|
struct IntegrityErrorItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var msgError: MsgErrorType
|
var msgError: MsgErrorType
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ struct IntegrityErrorItemView: View {
|
|||||||
|
|
||||||
struct CIMsgError: View {
|
struct CIMsgError: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var onTap: () -> Void
|
var onTap: () -> Void
|
||||||
|
|
||||||
@@ -62,13 +64,12 @@ struct CIMsgError: View {
|
|||||||
Text(chatItem.content.text)
|
Text(chatItem.content.text)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.italic()
|
.italic()
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture(perform: onTap)
|
.onTapGesture(perform: onTap)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct MarkedDeletedItemView: View {
|
struct MarkedDeletedItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
@@ -19,11 +19,10 @@ struct MarkedDeletedItemView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
|
|||||||
|
|
||||||
struct MsgContentView: View {
|
struct MsgContentView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var text: String
|
var text: String
|
||||||
var formattedText: [FormattedText]? = nil
|
var formattedText: [FormattedText]? = nil
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
@@ -65,7 +66,7 @@ struct MsgContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func msgContentView() -> Text {
|
private func msgContentView() -> Text {
|
||||||
var v = messageText(text, formattedText, sender, showSecrets: showSecrets)
|
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
|
||||||
if let mt = meta {
|
if let mt = meta {
|
||||||
if mt.isLive {
|
if mt.isLive {
|
||||||
v = v + typingIndicator(mt.recent)
|
v = v + typingIndicator(mt.recent)
|
||||||
@@ -79,7 +80,7 @@ struct MsgContentView: View {
|
|||||||
return (recent ? typingIndicators[typingIdx] : noTyping)
|
return (recent ? typingIndicators[typingIdx] : noTyping)
|
||||||
.font(.body.monospaced())
|
.font(.body.monospaced())
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||||
@@ -87,7 +88,7 @@ struct MsgContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool) -> Text {
|
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
|
||||||
let s = text
|
let s = text
|
||||||
var res: Text
|
var res: Text
|
||||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||||
@@ -102,7 +103,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let i = icon {
|
if let i = icon {
|
||||||
res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
|
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
|
||||||
}
|
}
|
||||||
|
|
||||||
if let s = sender {
|
if let s = sender {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ChatItemForwardingView: View {
|
struct ChatItemForwardingView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
var ci: ChatItem
|
var ci: ChatItem
|
||||||
@@ -19,6 +20,9 @@ struct ChatItemForwardingView: View {
|
|||||||
|
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
@FocusState private var searchFocused
|
@FocusState private var searchFocused
|
||||||
|
@State private var alert: SomeAlert?
|
||||||
|
@State private var hasSimplexLink_: Bool?
|
||||||
|
private let chatsToForwardTo = filterChatsToForwardTo()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
@@ -35,47 +39,35 @@ struct ChatItemForwardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground())
|
||||||
|
.alert(item: $alert) { $0.alert }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func forwardListView() -> some View {
|
@ViewBuilder private func forwardListView() -> some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let chatsToForwardTo = filterChatsToForwardTo()
|
|
||||||
if !chatsToForwardTo.isEmpty {
|
if !chatsToForwardTo.isEmpty {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocused)
|
.padding(.leading, 2)
|
||||||
.padding(.leading, 2)
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
|
||||||
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { filterChatSearched($0, s) }
|
ForEach(chats) { chat in
|
||||||
ForEach(chats) { chat in
|
forwardListChatView(chat)
|
||||||
Divider()
|
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||||
forwardListNavLinkView(chat)
|
|
||||||
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color(uiColor: .systemBackground))
|
|
||||||
.cornerRadius(12)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
ZStack {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filterChatsToForwardTo() -> [Chat] {
|
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
|
||||||
var filteredChats = chatModel.chats.filter({ canForwardToChat($0) })
|
|
||||||
if let index = filteredChats.firstIndex(where: { $0.chatInfo.chatType == .local }) {
|
|
||||||
let privateNotes = filteredChats.remove(at: index)
|
|
||||||
filteredChats.insert(privateNotes, at: 0)
|
|
||||||
}
|
|
||||||
return filteredChats
|
|
||||||
}
|
|
||||||
|
|
||||||
private func filterChatSearched(_ chat: Chat, _ searchStr: String) -> Bool {
|
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
return switch cInfo {
|
return switch cInfo {
|
||||||
case let .direct(contact):
|
case let .direct(contact):
|
||||||
@@ -91,42 +83,70 @@ struct ChatItemForwardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func canForwardToChat(_ chat: Chat) -> Bool {
|
private func prohibitedByPref(_ chat: Chat) -> Bool {
|
||||||
switch chat.chatInfo {
|
// preference checks should match checks in compose view
|
||||||
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
|
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||||
case let .group(groupInfo): groupInfo.sendMsgEnabled
|
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
|
||||||
case let .local(noteFolder): noteFolder.sendMsgEnabled
|
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
|
||||||
|
return switch chat.chatInfo {
|
||||||
|
case .direct: voiceProhibited
|
||||||
|
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
|
||||||
|
case .local: false
|
||||||
case .contactRequest: false
|
case .contactRequest: false
|
||||||
case .contactConnection: false
|
case .contactConnection: false
|
||||||
case .invalidJSON: false
|
case .invalidJSON: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasSimplexLink: Bool {
|
||||||
|
if let hasSimplexLink_ { return hasSimplexLink_ }
|
||||||
|
let r =
|
||||||
|
if let mcText = ci.content.msgContent?.text,
|
||||||
|
let parsedMsg = parseSimpleXMarkdown(mcText) {
|
||||||
|
parsedMsgHasSimplexLink(parsedMsg)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
hasSimplexLink_ = r
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
private func emptyList() -> some View {
|
private func emptyList() -> some View {
|
||||||
Text("No filtered chats")
|
Text("No filtered chats")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func forwardListNavLinkView(_ chat: Chat) -> some View {
|
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
|
||||||
|
let prohibited = prohibitedByPref(chat)
|
||||||
Button {
|
Button {
|
||||||
dismiss()
|
if prohibited {
|
||||||
if chat.id == fromChatInfo.id {
|
alert = SomeAlert(
|
||||||
composeState = ComposeState(
|
alert: mkAlert(
|
||||||
message: composeState.message,
|
title: "Cannot forward message",
|
||||||
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
|
message: "Selected chat preferences prohibit this message."
|
||||||
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
|
),
|
||||||
)
|
id: "forward prohibited by preferences"
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
dismiss()
|
||||||
chatModel.chatId = chat.id
|
if chat.id == fromChatInfo.id {
|
||||||
|
composeState = ComposeState(
|
||||||
|
message: composeState.message,
|
||||||
|
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
|
||||||
|
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
||||||
|
chatModel.chatId = chat.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
ChatInfoImage(chat: chat, size: 30)
|
ChatInfoImage(chat: chat, size: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(chat.chatInfo.chatViewName)
|
Text(chat.chatInfo.chatViewName)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(prohibited ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if chat.chatInfo.incognito {
|
if chat.chatInfo.incognito {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -134,7 +154,7 @@ struct ChatItemForwardingView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -142,10 +162,31 @@ struct ChatItemForwardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func filterChatsToForwardTo() -> [Chat] {
|
||||||
|
var filteredChats = ChatModel.shared.chats.filter { c in
|
||||||
|
c.chatInfo.chatType != .local && canForwardToChat(c)
|
||||||
|
}
|
||||||
|
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
|
||||||
|
filteredChats.insert(privateNotes, at: 0)
|
||||||
|
}
|
||||||
|
return filteredChats
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canForwardToChat(_ chat: Chat) -> Bool {
|
||||||
|
switch chat.chatInfo {
|
||||||
|
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
|
||||||
|
case let .group(groupInfo): groupInfo.sendMsgEnabled
|
||||||
|
case let .local(noteFolder): noteFolder.sendMsgEnabled
|
||||||
|
case .contactRequest: false
|
||||||
|
case .contactConnection: false
|
||||||
|
case .invalidJSON: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ChatItemForwardingView(
|
ChatItemForwardingView(
|
||||||
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||||
fromChatInfo: .direct(contact: Contact.sampleData),
|
fromChatInfo: .direct(contact: Contact.sampleData),
|
||||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||||
)
|
).environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import SimpleXChat
|
|||||||
struct ChatItemInfoView: View {
|
struct ChatItemInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var ci: ChatItem
|
var ci: ChatItem
|
||||||
@Binding var chatItemInfo: ChatItemInfo?
|
@Binding var chatItemInfo: ChatItemInfo?
|
||||||
@State private var selection: CIInfoTab = .history
|
@State private var selection: CIInfoTab = .history
|
||||||
@State private var alert: CIInfoViewAlert? = nil
|
@State private var alert: CIInfoViewAlert? = nil
|
||||||
|
@State private var messageStatusLimited: Bool = true
|
||||||
|
@State private var fileStatusLimited: Bool = true
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
|
||||||
enum CIInfoTab {
|
enum CIInfoTab {
|
||||||
@@ -99,12 +101,14 @@ struct ChatItemInfoView: View {
|
|||||||
Label("History", systemImage: "clock")
|
Label("History", systemImage: "clock")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.history)
|
.tag(CIInfoTab.history)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
if let qi = ci.quotedItem {
|
if let qi = ci.quotedItem {
|
||||||
quoteTab(qi)
|
quoteTab(qi)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.quote)
|
.tag(CIInfoTab.quote)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
|
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
|
||||||
forwardedFromTab(forwardedFromItem)
|
forwardedFromTab(forwardedFromItem)
|
||||||
@@ -112,6 +116,7 @@ struct ChatItemInfoView: View {
|
|||||||
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
|
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.forwarded)
|
.tag(CIInfoTab.forwarded)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -121,6 +126,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
historyTab()
|
historyTab()
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +163,35 @@ struct ChatItemInfoView: View {
|
|||||||
if developerTools {
|
if developerTools {
|
||||||
infoRow("Database ID", "\(meta.itemId)")
|
infoRow("Database ID", "\(meta.itemId)")
|
||||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||||
|
let msv = infoRow("Message status", ci.meta.itemStatus.id)
|
||||||
|
Group {
|
||||||
|
if messageStatusLimited {
|
||||||
|
msv.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
msv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
messageStatusLimited.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let file = ci.file {
|
||||||
|
let fsv = infoRow("File status", file.fileStatus.id)
|
||||||
|
Group {
|
||||||
|
if fileStatusLimited {
|
||||||
|
fsv.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
fsv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
fileStatusLimited.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +216,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Text("No history")
|
Text("No history")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,8 +231,8 @@ struct ChatItemInfoView: View {
|
|||||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(ci, colorScheme))
|
.background(chatItemFrameColor(ci, theme))
|
||||||
.cornerRadius(18)
|
.modifier(ChatItemClipped())
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if itemVersion.msgContent.text != "" {
|
if itemVersion.msgContent.text != "" {
|
||||||
Button {
|
Button {
|
||||||
@@ -227,18 +262,19 @@ struct ChatItemInfoView: View {
|
|||||||
} else {
|
} else {
|
||||||
Text("no text")
|
Text("no text")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TextBubble: View {
|
private struct TextBubble: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var text: String
|
var text: String
|
||||||
var formattedText: [FormattedText]?
|
var formattedText: [FormattedText]?
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
@State private var showSecrets = false
|
@State private var showSecrets = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets))
|
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +301,8 @@ struct ChatItemInfoView: View {
|
|||||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(quotedMsgFrameColor(qi, colorScheme))
|
.background(quotedMsgFrameColor(qi, theme))
|
||||||
.cornerRadius(18)
|
.modifier(ChatItemClipped())
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if qi.text != "" {
|
if qi.text != "" {
|
||||||
Button {
|
Button {
|
||||||
@@ -289,10 +325,10 @@ struct ChatItemInfoView: View {
|
|||||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
|
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color {
|
||||||
(qi.chatDir?.sent ?? false)
|
(qi.chatDir?.sent ?? false)
|
||||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
? theme.appColors.sentMessage
|
||||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
: theme.appColors.receivedMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
||||||
@@ -327,7 +363,7 @@ struct ChatItemInfoView: View {
|
|||||||
Divider().padding(.top, 32)
|
Divider().padding(.top, 32)
|
||||||
Text("Recipient(s) can't see who this message is from.")
|
Text("Recipient(s) can't see who this message is from.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,23 +377,23 @@ struct ChatItemInfoView: View {
|
|||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("you")
|
Text("you")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
Text(forwardedFromItem.chatInfo.chatViewName)
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
|
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(groupMember.chatViewName)
|
Text(groupMember.chatViewName)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(forwardedFromItem.chatInfo.chatViewName)
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(forwardedFromItem.chatInfo.chatViewName)
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,7 +415,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
let mss = membersStatuses(memberDeliveryStatuses)
|
let mss = membersStatuses(memberDeliveryStatuses)
|
||||||
if !mss.isEmpty {
|
if !mss.isEmpty {
|
||||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||||
@@ -387,12 +423,12 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No delivery information")
|
Text("No delivery information")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
|
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
|
||||||
memberDeliveryStatuses.compactMap({ mds in
|
memberDeliveryStatuses.compactMap({ mds in
|
||||||
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
||||||
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
||||||
@@ -402,7 +438,7 @@ struct ChatItemInfoView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
|
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
|
||||||
HStack{
|
HStack{
|
||||||
ProfileImage(imageStr: member.image, size: 30)
|
ProfileImage(imageStr: member.image, size: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
@@ -411,26 +447,22 @@ struct ChatItemInfoView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if sentViaProxy == true {
|
if sentViaProxy == true {
|
||||||
Image(systemName: "arrow.forward")
|
Image(systemName: "arrow.forward")
|
||||||
.foregroundColor(.secondary).opacity(0.67)
|
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
||||||
}
|
}
|
||||||
let v = Group {
|
let v = Group {
|
||||||
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
|
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
|
||||||
switch status {
|
switch status {
|
||||||
case .sndRcvd:
|
case .rcvd:
|
||||||
ZStack(alignment: .trailing) {
|
ZStack(alignment: .trailing) {
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(statusColor.opacity(0.67))
|
|
||||||
.padding(.trailing, 6)
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(statusColor.opacity(0.67))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.foregroundColor(statusColor)
|
.foregroundColor(statusColor.opacity(0.67))
|
||||||
|
.padding(.trailing, 6)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundColor(statusColor.opacity(0.67))
|
||||||
}
|
}
|
||||||
} else {
|
default:
|
||||||
Image(systemName: "ellipsis")
|
Image(systemName: icon)
|
||||||
.foregroundColor(Color.secondary)
|
.foregroundColor(statusColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,8 +505,12 @@ struct ChatItemInfoView: View {
|
|||||||
if developerTools {
|
if developerTools {
|
||||||
shareText += [
|
shareText += [
|
||||||
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
||||||
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
|
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)),
|
||||||
|
String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id)
|
||||||
]
|
]
|
||||||
|
if let file = ci.file {
|
||||||
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let qi = ci.quotedItem {
|
if let qi = ci.quotedItem {
|
||||||
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ChatItemView: View {
|
struct ChatItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var maxWidth: CGFloat = .infinity
|
var maxWidth: CGFloat = .infinity
|
||||||
@State var scrollProxy: ScrollViewProxy? = nil
|
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
@Binding var audioPlayer: AudioPlayer?
|
||||||
@@ -24,7 +24,6 @@ struct ChatItemView: View {
|
|||||||
chatItem: ChatItem,
|
chatItem: ChatItem,
|
||||||
showMember: Bool = false,
|
showMember: Bool = false,
|
||||||
maxWidth: CGFloat = .infinity,
|
maxWidth: CGFloat = .infinity,
|
||||||
scrollProxy: ScrollViewProxy? = nil,
|
|
||||||
revealed: Binding<Bool>,
|
revealed: Binding<Bool>,
|
||||||
allowMenu: Binding<Bool> = .constant(false),
|
allowMenu: Binding<Bool> = .constant(false),
|
||||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
||||||
@@ -34,7 +33,6 @@ struct ChatItemView: View {
|
|||||||
self.chat = chat
|
self.chat = chat
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
_scrollProxy = .init(initialValue: scrollProxy)
|
|
||||||
_revealed = revealed
|
_revealed = revealed
|
||||||
_allowMenu = allowMenu
|
_allowMenu = allowMenu
|
||||||
_audioPlayer = audioPlayer
|
_audioPlayer = audioPlayer
|
||||||
@@ -62,12 +60,43 @@ struct ChatItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func framedItemView() -> some View {
|
private func framedItemView() -> some View {
|
||||||
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
let preview = chatItem.content.msgContent
|
||||||
|
.flatMap {
|
||||||
|
switch $0 {
|
||||||
|
case let .image(_, image): image
|
||||||
|
case let .video(_, image, _): image
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { dropImagePrefix($0) }
|
||||||
|
.flatMap { Data(base64Encoded: $0) }
|
||||||
|
.flatMap { UIImage(data: $0) }
|
||||||
|
let adjustedMaxWidth = {
|
||||||
|
if let preview, preview.size.width <= preview.size.height {
|
||||||
|
maxWidth * 0.75
|
||||||
|
} else {
|
||||||
|
maxWidth
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return FramedItemView(
|
||||||
|
chat: chat,
|
||||||
|
chatItem: chatItem,
|
||||||
|
preview: preview,
|
||||||
|
revealed: $revealed,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
imgWidth: adjustedMaxWidth,
|
||||||
|
videoWidth: adjustedMaxWidth,
|
||||||
|
allowMenu: $allowMenu,
|
||||||
|
audioPlayer: $audioPlayer,
|
||||||
|
playbackState: $playbackState,
|
||||||
|
playbackTime: $playbackTime
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemContentView<Content: View>: View {
|
struct ChatItemContentView<Content: View>: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
@@ -97,14 +126,14 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
case .sndGroupEvent: eventItemView()
|
case .sndGroupEvent: eventItemView()
|
||||||
case .rcvConnEvent: eventItemView()
|
case .rcvConnEvent: eventItemView()
|
||||||
case .sndConnEvent: eventItemView()
|
case .sndConnEvent: eventItemView()
|
||||||
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
|
||||||
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
|
||||||
case let .rcvChatPreference(feature, allowed, param):
|
case let .rcvChatPreference(feature, allowed, param):
|
||||||
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||||
case let .sndChatPreference(feature, _, _):
|
case let .sndChatPreference(feature, _, _):
|
||||||
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
|
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
|
||||||
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor)
|
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||||
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor)
|
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
case .sndModerated: deletedItemView()
|
case .sndModerated: deletedItemView()
|
||||||
@@ -131,20 +160,20 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func eventItemView() -> some View {
|
private func eventItemView() -> some View {
|
||||||
return CIEventView(eventText: eventItemViewText())
|
CIEventView(eventText: eventItemViewText(theme.colors.secondary))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func eventItemViewText() -> Text {
|
private func eventItemViewText(_ secondaryColor: Color) -> Text {
|
||||||
if !revealed, let t = mergedGroupEventText {
|
if !revealed, let t = mergedGroupEventText {
|
||||||
return chatEventText(t + Text(" ") + chatItem.timestampText)
|
return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
|
||||||
} else if let member = chatItem.memberDisplayName {
|
} else if let member = chatItem.memberDisplayName {
|
||||||
return Text(member + " ")
|
return Text(member + " ")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
+ chatEventText(chatItem)
|
+ chatEventText(chatItem, secondaryColor)
|
||||||
} else {
|
} else {
|
||||||
return chatEventText(chatItem)
|
return chatEventText(chatItem, secondaryColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +208,7 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
info.pqEnabled
|
info.pqEnabled
|
||||||
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
: e2eeInfoNoPQText()
|
: e2eeInfoNoPQText()
|
||||||
}
|
}
|
||||||
@@ -187,24 +216,24 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
private func e2eeInfoNoPQText() -> Text {
|
private func e2eeInfoNoPQText() -> Text {
|
||||||
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ text: Text) -> Text {
|
func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
|
||||||
text
|
text
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
|
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
|
||||||
chatEventText(Text(eventText) + Text(" ") + ts)
|
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ ci: ChatItem) -> Text {
|
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
||||||
chatEventText("\(ci.content.text)", ci.timestampText)
|
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemView_Previews: PreviewProvider {
|
struct ChatItemView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ComposeFileView: View {
|
struct ComposeFileView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let fileName: String
|
let fileName: String
|
||||||
let cancelFile: (() -> Void)
|
let cancelFile: (() -> Void)
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -33,7 +33,7 @@ struct ComposeFileView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ComposeImageView: View {
|
struct ComposeImageView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let images: [String]
|
let images: [String]
|
||||||
let cancelImage: (() -> Void)
|
let cancelImage: (() -> Void)
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -48,7 +48,7 @@ struct ComposeImageView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ComposeLinkView: View {
|
struct ComposeLinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview?
|
let linkPreview: LinkPreview?
|
||||||
var cancelPreview: (() -> Void)? = nil
|
var cancelPreview: (() -> Void)? = nil
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -62,7 +62,7 @@ struct ComposeLinkView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ struct ComposeLinkView: View {
|
|||||||
Text(linkPreview.uri.absoluteString)
|
Text(linkPreview.uri.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
|
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ enum UploadContent: Equatable {
|
|||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
@Binding var keyboardVisible: Bool
|
@Binding var keyboardVisible: Bool
|
||||||
@@ -286,6 +287,7 @@ struct ComposeView: View {
|
|||||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||||
ContextInvitingContactMemberView()
|
ContextInvitingContactMemberView()
|
||||||
}
|
}
|
||||||
|
// preference checks should match checks in forwarding list
|
||||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||||
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
||||||
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
||||||
@@ -313,6 +315,7 @@ struct ComposeView: View {
|
|||||||
.frame(width: 25, height: 25)
|
.frame(width: 25, height: 25)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
|
.tint(theme.colors.primary)
|
||||||
if case let .group(g) = chat.chatInfo,
|
if case let .group(g) = chat.chatInfo,
|
||||||
!g.fullGroupPreferences.files.on(for: g.membership) {
|
!g.fullGroupPreferences.files.on(for: g.membership) {
|
||||||
b.disabled(true).onTapGesture {
|
b.disabled(true).onTapGesture {
|
||||||
@@ -353,16 +356,16 @@ struct ComposeView: View {
|
|||||||
keyboardVisible: $keyboardVisible,
|
keyboardVisible: $keyboardVisible,
|
||||||
sendButtonColor: chat.chatInfo.incognito
|
sendButtonColor: chat.chatInfo.incognito
|
||||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||||
: .accentColor
|
: theme.colors.primary
|
||||||
)
|
)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(.background)
|
.background(theme.colors.background)
|
||||||
.disabled(!chat.userCanSend)
|
.disabled(!chat.userCanSend)
|
||||||
|
|
||||||
if chat.userIsObserver {
|
if chat.userIsObserver {
|
||||||
Text("you are observer")
|
Text("you are observer")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -654,7 +657,7 @@ struct ComposeView: View {
|
|||||||
|
|
||||||
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
|
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: icon).foregroundColor(.secondary)
|
Image(systemName: icon).foregroundColor(theme.colors.secondary)
|
||||||
Text(reason).italic()
|
Text(reason).italic()
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
@@ -1065,7 +1068,7 @@ struct ComposeView: View {
|
|||||||
} else {
|
} else {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
let simplexLink = parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
|
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
|
||||||
return (url, simplexLink)
|
return (url, simplexLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1105,6 +1108,10 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
|
||||||
|
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
|
||||||
|
}
|
||||||
|
|
||||||
struct ComposeView_Previews: PreviewProvider {
|
struct ComposeView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
|
|||||||
|
|
||||||
struct ComposeVoiceView: View {
|
struct ComposeVoiceView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var recordingFileName: String
|
var recordingFileName: String
|
||||||
@Binding var recordingTime: TimeInterval?
|
@Binding var recordingTime: TimeInterval?
|
||||||
@Binding var recordingState: VoiceMessageRecordingState
|
@Binding var recordingState: VoiceMessageRecordingState
|
||||||
@@ -50,7 +50,7 @@ struct ComposeVoiceView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.frame(height: ComposeVoiceView.previewHeight)
|
.frame(height: ComposeVoiceView.previewHeight)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
|
|||||||
Button {
|
Button {
|
||||||
startPlayback()
|
startPlayback()
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(recordingTime))
|
Text(voiceMessageTime_(recordingTime))
|
||||||
case .playing:
|
case .playing:
|
||||||
@@ -88,7 +88,7 @@ struct ComposeVoiceView: View {
|
|||||||
audioPlayer?.pause()
|
audioPlayer?.pause()
|
||||||
playbackState = .paused
|
playbackState = .paused
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("pause.fill")
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(playbackTime))
|
Text(voiceMessageTime_(playbackTime))
|
||||||
case .paused:
|
case .paused:
|
||||||
@@ -96,7 +96,7 @@ struct ComposeVoiceView: View {
|
|||||||
audioPlayer?.play()
|
audioPlayer?.play()
|
||||||
playbackState = .playing
|
playbackState = .playing
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(playbackTime))
|
Text(voiceMessageTime_(playbackTime))
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color) -> some View {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
@@ -147,9 +147,11 @@ struct ComposeVoiceView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SliderBar: View {
|
struct SliderBar: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
var seek: (TimeInterval) -> Void
|
var seek: (TimeInterval) -> Void
|
||||||
@@ -158,10 +160,12 @@ struct ComposeVoiceView: View {
|
|||||||
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
|
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 4)
|
.frame(height: 4)
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProgressBar: View {
|
private struct ProgressBar: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ struct ComposeVoiceView: View {
|
|||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.accentColor)
|
.fill(theme.colors.primary)
|
||||||
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
||||||
.animation(.linear, value: progress)
|
.animation(.linear, value: progress)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContextInvitingContactMemberView: View {
|
struct ContextInvitingContactMemberView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "message")
|
Image(systemName: "message")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
Text("Send direct message to connect")
|
Text("Send direct message to connect")
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 50)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ContextItemView: View {
|
struct ContextItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
let contextItem: ChatItem
|
let contextItem: ChatItem
|
||||||
let contextIcon: String
|
let contextIcon: String
|
||||||
@@ -23,10 +23,10 @@ struct ContextItemView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
if showSender, let sender = contextItem.memberDisplayName {
|
if showSender, let sender = contextItem.memberDisplayName {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(sender).font(.caption).foregroundColor(.secondary)
|
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
|
||||||
msgContentView(lines: 2)
|
msgContentView(lines: 2)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -40,11 +40,12 @@ struct ContextItemView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 50)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(chatItemFrameColor(contextItem, colorScheme))
|
.background(chatItemFrameColor(contextItem, theme))
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ struct ContextItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func contextMsgPreview() -> Text {
|
private func contextMsgPreview() -> Text {
|
||||||
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false)
|
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
|
|
||||||
func attachment() -> Text {
|
func attachment() -> Text {
|
||||||
switch contextItem.content.msgContent {
|
switch contextItem.content.msgContent {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let field = CustomUITextField(height: _height)
|
let field = CustomUITextField(height: _height)
|
||||||
|
field.backgroundColor = .clear
|
||||||
field.text = text
|
field.text = text
|
||||||
field.textAlignment = alignment(text)
|
field.textAlignment = alignment(text)
|
||||||
field.autocapitalizationType = .sentences
|
field.autocapitalizationType = .sentences
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
|
|||||||
|
|
||||||
struct SendMessageView: View {
|
struct SendMessageView: View {
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var sendMessage: (Int?) -> Void
|
var sendMessage: (Int?) -> Void
|
||||||
var sendLiveMessage: (() async -> Void)? = nil
|
var sendLiveMessage: (() async -> Void)? = nil
|
||||||
var updateLiveMessage: (() async -> Void)? = nil
|
var updateLiveMessage: (() async -> Void)? = nil
|
||||||
@@ -49,7 +50,7 @@ struct SendMessageView: View {
|
|||||||
Text("Voice message…")
|
Text("Voice message…")
|
||||||
.font(teFont.italic())
|
.font(teFont.italic())
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -247,6 +248,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct RecordVoiceMessageButton: View {
|
private struct RecordVoiceMessageButton: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var startVoiceMessageRecording: (() -> Void)?
|
var startVoiceMessageRecording: (() -> Void)?
|
||||||
var finishVoiceMessageRecording: (() -> Void)?
|
var finishVoiceMessageRecording: (() -> Void)?
|
||||||
@Binding var holdingVMR: Bool
|
@Binding var holdingVMR: Bool
|
||||||
@@ -256,7 +258,7 @@ struct SendMessageView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {}) {
|
Button(action: {}) {
|
||||||
Image(systemName: "mic.fill")
|
Image(systemName: "mic.fill")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -309,7 +311,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "mic")
|
Image(systemName: "mic")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.disabled(composeState.inProgress)
|
.disabled(composeState.inProgress)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -323,7 +325,7 @@ struct SendMessageView: View {
|
|||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.frame(width: 15, height: 15)
|
.frame(width: 15, height: 15)
|
||||||
}
|
}
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -340,7 +342,7 @@ struct SendMessageView: View {
|
|||||||
Image(systemName: "bolt.fill")
|
Image(systemName: "bolt.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
}
|
}
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -383,7 +385,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||||
while composeState.liveMessage != nil {
|
while await composeState.liveMessage != nil {
|
||||||
await update()
|
await update()
|
||||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||||
}
|
}
|
||||||
@@ -394,7 +396,7 @@ struct SendMessageView: View {
|
|||||||
private func finishVoiceMessageRecordingButton() -> some View {
|
private func finishVoiceMessageRecordingButton() -> some View {
|
||||||
Button(action: { finishVoiceMessageRecording?() }) {
|
Button(action: { finishVoiceMessageRecording?() }) {
|
||||||
Image(systemName: "stop.fill")
|
Image(systemName: "stop.fill")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.disabled(composeState.inProgress)
|
.disabled(composeState.inProgress)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
|||||||
struct ContactPreferencesView: View {
|
struct ContactPreferencesView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var contact: Contact
|
@Binding var contact: Contact
|
||||||
@State var featuresAllowed: ContactFeaturesAllowed
|
@State var featuresAllowed: ContactFeaturesAllowed
|
||||||
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||||
@@ -66,8 +67,8 @@ struct ContactPreferencesView: View {
|
|||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||||
}
|
}
|
||||||
header: { featureHeader(feature, enabled) }
|
header: { featureHeader(feature, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
footer: { featureFooter(feature, enabled) }
|
footer: { featureFooter(feature, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timedMessagesFeatureSection() -> some View {
|
private func timedMessagesFeatureSection() -> some View {
|
||||||
@@ -102,8 +103,8 @@ struct ContactPreferencesView: View {
|
|||||||
infoRow("Delete after", timeText(pref.contactPreference.ttl))
|
infoRow("Delete after", timeText(pref.contactPreference.ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
header: { featureHeader(.timedMessages, enabled) }
|
header: { featureHeader(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
footer: { featureFooter(.timedMessages, enabled) }
|
footer: { featureFooter(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct AddGroupMembersView: View {
|
|||||||
|
|
||||||
struct AddGroupMembersViewCommon: View {
|
struct AddGroupMembersViewCommon: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chat: Chat
|
var chat: Chat
|
||||||
@State var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
var creatingGroup: Bool = false
|
var creatingGroup: Bool = false
|
||||||
@@ -70,7 +71,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
|
|
||||||
if (membersToAdd.isEmpty) {
|
if (membersToAdd.isEmpty) {
|
||||||
Text("No contacts to add")
|
Text("No contacts to add")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -90,16 +91,18 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
|
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(count) contact(s) selected")
|
Text("\(count) contact(s) selected")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No contacts selected")
|
Text("No contacts selected")
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.primary, theme.colors.secondary)
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||||
@@ -125,6 +128,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
.onChange(of: selectedContacts) { _ in
|
.onChange(of: selectedContacts) { _ in
|
||||||
searchFocussed = false
|
searchFocussed = false
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func inviteMembersButton() -> some View {
|
private func inviteMembersButton() -> some View {
|
||||||
@@ -172,14 +176,14 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
var iconColor: Color
|
var iconColor: Color
|
||||||
if prohibitedToInviteIncognito {
|
if prohibitedToInviteIncognito {
|
||||||
icon = "theatermasks.circle.fill"
|
icon = "theatermasks.circle.fill"
|
||||||
iconColor = Color(uiColor: .tertiaryLabel)
|
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||||
} else {
|
} else {
|
||||||
if checked {
|
if checked {
|
||||||
icon = "checkmark.circle.fill"
|
icon = "checkmark.circle.fill"
|
||||||
iconColor = .accentColor
|
iconColor = theme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
icon = "circle"
|
icon = "circle"
|
||||||
iconColor = Color(uiColor: .tertiaryLabel)
|
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Button {
|
return Button {
|
||||||
@@ -197,7 +201,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
ProfileImage(imageStr: contact.image, size: 30)
|
ProfileImage(imageStr: contact.image, size: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(ChatInfo.direct(contact: contact).chatViewName)
|
Text(ChatInfo.direct(contact: contact).chatViewName)
|
||||||
.foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
|
.foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
@@ -207,7 +211,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding) -> some View {
|
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding, _ onBackgroundColor: Color, _ secondaryColor: Color) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -216,7 +220,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
|
|||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
TextField("Search", text: text)
|
TextField("Search", text: text)
|
||||||
.focused(focussed)
|
.focused(focussed)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(onBackgroundColor)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -228,7 +232,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
|
|||||||
focussed.wrappedValue = false
|
focussed.wrappedValue = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
|||||||
|
|
||||||
struct GroupChatInfoView: View {
|
struct GroupChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@@ -81,13 +82,19 @@ struct GroupChatInfoView: View {
|
|||||||
} else {
|
} else {
|
||||||
sendReceiptsOptionDisabled()
|
sendReceiptsOptionDisabled()
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ChatWallpaperEditorSheet(chat: chat)
|
||||||
|
} label: {
|
||||||
|
Label("Chat theme", systemImage: "photo")
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("")
|
Text("")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Only group owners can change group preferences.")
|
Text("Only group owners can change group preferences.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("\(members.count + 1) members") {
|
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||||
if groupInfo.canAddMembers {
|
if groupInfo.canAddMembers {
|
||||||
groupLinkButton()
|
groupLinkButton()
|
||||||
if (chat.chatInfo.incognito) {
|
if (chat.chatInfo.incognito) {
|
||||||
@@ -99,7 +106,7 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if members.count > 8 {
|
if members.count > 8 {
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
@@ -129,12 +136,13 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section(header: Text("For console")) {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
@@ -199,6 +207,7 @@ struct GroupChatInfoView: View {
|
|||||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||||
|
chatModel.populateGroupMembersIndexes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +219,7 @@ struct GroupChatInfoView: View {
|
|||||||
private struct MemberRowView: View {
|
private struct MemberRowView: View {
|
||||||
var groupInfo: GroupInfo
|
var groupInfo: GroupInfo
|
||||||
@ObservedObject var groupMember: GMember
|
@ObservedObject var groupMember: GMember
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var user: Bool = false
|
var user: Bool = false
|
||||||
@Binding var alert: GroupChatInfoViewAlert?
|
@Binding var alert: GroupChatInfoViewAlert?
|
||||||
|
|
||||||
@@ -220,14 +230,13 @@ struct GroupChatInfoView: View {
|
|||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
// TODO server connection status
|
// TODO server connection status
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
||||||
(member.verified ? memberVerifiedShield + t : t)
|
(member.verified ? memberVerifiedShield + t : t)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
let s = Text(member.memberStatus.shortText)
|
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
|
||||||
(user ? Text ("you: ") + s : s)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
memberInfo(member)
|
memberInfo(member)
|
||||||
@@ -257,15 +266,25 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||||
|
if member.activeConn?.connDisabled ?? false {
|
||||||
|
return "disabled"
|
||||||
|
} else if member.activeConn?.connInactive ?? false {
|
||||||
|
return "inactive"
|
||||||
|
} else {
|
||||||
|
return member.memberStatus.shortText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||||
if member.blocked {
|
if member.blocked {
|
||||||
Text("blocked")
|
Text("blocked")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
let role = member.memberRole
|
let role = member.memberRole
|
||||||
if [.owner, .admin, .observer].contains(role) {
|
if [.owner, .admin, .observer].contains(role) {
|
||||||
Text(member.memberRole.text)
|
Text(member.memberRole.text)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,13 +295,13 @@ struct GroupChatInfoView: View {
|
|||||||
Button {
|
Button {
|
||||||
alert = .blockMemberAlert(mem: member)
|
alert = .blockMemberAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
|
Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
alert = .unblockMemberAlert(mem: member)
|
alert = .unblockMemberAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
|
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,13 +313,13 @@ struct GroupChatInfoView: View {
|
|||||||
Button {
|
Button {
|
||||||
alert = .unblockForAllAlert(mem: member)
|
alert = .unblockForAllAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
|
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
alert = .blockForAllAlert(mem: member)
|
alert = .blockForAllAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Block for all", systemImage: "hand.raised").foregroundColor(.secondary)
|
Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +335,14 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var memberVerifiedShield: Text {
|
||||||
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
|
.font(.caption)
|
||||||
|
.baselineOffset(2)
|
||||||
|
.kerning(-2)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||||
@@ -333,6 +360,7 @@ struct GroupChatInfoView: View {
|
|||||||
creatingGroup: false
|
creatingGroup: false
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group link")
|
.navigationBarTitle("Group link")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
if groupLink == nil {
|
if groupLink == nil {
|
||||||
@@ -350,6 +378,7 @@ struct GroupChatInfoView: View {
|
|||||||
groupProfile: groupInfo.groupProfile
|
groupProfile: groupInfo.groupProfile
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group profile")
|
.navigationBarTitle("Group profile")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Edit group profile", systemImage: "pencil")
|
Label("Edit group profile", systemImage: "pencil")
|
||||||
@@ -364,6 +393,7 @@ struct GroupChatInfoView: View {
|
|||||||
welcomeText: groupInfo.groupProfile.description ?? ""
|
welcomeText: groupInfo.groupProfile.description ?? ""
|
||||||
)
|
)
|
||||||
.navigationTitle("Welcome message")
|
.navigationTitle("Welcome message")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
groupInfo.groupProfile.description == nil
|
groupInfo.groupProfile.description == nil
|
||||||
@@ -518,6 +548,7 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
|||||||
creatingGroup: creatingGroup
|
creatingGroup: creatingGroup
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group preferences")
|
.navigationBarTitle("Group preferences")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
if creatingGroup {
|
if creatingGroup {
|
||||||
@@ -528,14 +559,6 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var memberVerifiedShield: Text {
|
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
|
||||||
.font(.caption)
|
|
||||||
.baselineOffset(2)
|
|
||||||
.kerning(-2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cantInviteIncognitoAlert() -> Alert {
|
func cantInviteIncognitoAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Can't invite contacts!"),
|
title: Text("Can't invite contacts!"),
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ struct GroupLinkView: View {
|
|||||||
shouldCreate = false
|
shouldCreate = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createGroupLink() {
|
private func createGroupLink() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct GroupMemberInfoView: View {
|
struct GroupMemberInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@State var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
@ObservedObject var groupMember: GMember
|
@ObservedObject var groupMember: GMember
|
||||||
@@ -35,6 +36,7 @@ struct GroupMemberInfoView: View {
|
|||||||
case abortSwitchAddressAlert
|
case abortSwitchAddressAlert
|
||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||||
|
case queueInfo(info: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
@@ -49,6 +51,7 @@ struct GroupMemberInfoView: View {
|
|||||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||||
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,12 +121,14 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Address")
|
Text("Address")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Member") {
|
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Group", groupInfo.displayName)
|
infoRow("Group", groupInfo.displayName)
|
||||||
|
|
||||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||||
@@ -136,16 +141,10 @@ struct GroupMemberInfoView: View {
|
|||||||
} else {
|
} else {
|
||||||
infoRow("Role", member.memberRole.text)
|
infoRow("Role", member.memberRole.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO invited by - need to get contact by contact id
|
|
||||||
if let conn = member.activeConn {
|
|
||||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
|
||||||
infoRow("Connection", connLevelDesc)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let connStats = connectionStats {
|
if let connStats = connectionStats {
|
||||||
Section("Servers") {
|
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||||
// TODO network connection status
|
// TODO network connection status
|
||||||
Button("Change receiving address") {
|
Button("Change receiving address") {
|
||||||
alert = .switchAddressAlert
|
alert = .switchAddressAlert
|
||||||
@@ -163,8 +162,8 @@ struct GroupMemberInfoView: View {
|
|||||||
|| connStats.ratchetSyncSendProhibited
|
|| connStats.ratchetSyncSendProhibited
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,9 +174,25 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section("For console") {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", member.localDisplayName)
|
infoRow("Local name", member.localDisplayName)
|
||||||
infoRow("Database ID", "\(member.groupMemberId)")
|
infoRow("Database ID", "\(member.groupMemberId)")
|
||||||
|
if let conn = member.activeConn {
|
||||||
|
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||||
|
infoRow("Connection", connLevelDesc)
|
||||||
|
}
|
||||||
|
Button ("Debug delivery") {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||||
|
await MainActor.run { alert = .queueInfo(info: info) }
|
||||||
|
} catch let e {
|
||||||
|
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||||
|
let a = getErrorAlert(e, "Error")
|
||||||
|
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +238,7 @@ struct GroupMemberInfoView: View {
|
|||||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||||
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +248,7 @@ struct GroupMemberInfoView: View {
|
|||||||
ProgressView().scaleEffect(2)
|
ProgressView().scaleEffect(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||||
@@ -311,7 +328,7 @@ struct GroupMemberInfoView: View {
|
|||||||
if mem.verified {
|
if mem.verified {
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "checkmark.shield"))
|
Text(Image(systemName: "checkmark.shield"))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text(mem.displayName)
|
+ Text(mem.displayName)
|
||||||
@@ -359,6 +376,7 @@ struct GroupMemberInfoView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("Security code")
|
.navigationTitle("Security code")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
member.verified ? "View security code" : "Verify security code",
|
member.verified ? "View security code" : "Verify security code",
|
||||||
@@ -408,7 +426,7 @@ struct GroupMemberInfoView: View {
|
|||||||
Section {
|
Section {
|
||||||
if mem.blockedByAdmin {
|
if mem.blockedByAdmin {
|
||||||
Label("Blocked by admin", systemImage: "hand.raised")
|
Label("Blocked by admin", systemImage: "hand.raised")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else if mem.memberSettings.showMessages {
|
} else if mem.memberSettings.showMessages {
|
||||||
blockMemberButton(mem)
|
blockMemberButton(mem)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] =
|
|||||||
struct GroupPreferencesView: View {
|
struct GroupPreferencesView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@State var preferences: FullGroupPreferences
|
@State var preferences: FullGroupPreferences
|
||||||
@State var currentPreferences: FullGroupPreferences
|
@State var currentPreferences: FullGroupPreferences
|
||||||
@@ -34,8 +35,7 @@ struct GroupPreferencesView: View {
|
|||||||
featureSection(.reactions, $preferences.reactions.enable)
|
featureSection(.reactions, $preferences.reactions.enable)
|
||||||
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
|
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
|
||||||
featureSection(.files, $preferences.files.enable, $preferences.files.role)
|
featureSection(.files, $preferences.files.enable, $preferences.files.role)
|
||||||
// TODO enable simplexLinks preference in 5.8
|
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
||||||
// featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
|
||||||
featureSection(.history, $preferences.history.enable)
|
featureSection(.history, $preferences.history.enable)
|
||||||
|
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
@@ -74,7 +74,7 @@ struct GroupPreferencesView: View {
|
|||||||
|
|
||||||
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
|
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
|
||||||
Section {
|
Section {
|
||||||
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
|
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
|
||||||
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
||||||
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
@@ -102,8 +102,6 @@ struct GroupPreferencesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
// remove in v5.8
|
|
||||||
.disabled(true)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
settingsRow(icon, color: color) {
|
settingsRow(icon, color: color) {
|
||||||
@@ -114,18 +112,19 @@ struct GroupPreferencesView: View {
|
|||||||
}
|
}
|
||||||
if enableFeature.wrappedValue == .on, let enableForRole {
|
if enableFeature.wrappedValue == .on, let enableForRole {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Enabled for").foregroundColor(.secondary)
|
Text("Enabled for").foregroundColor(theme.colors.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(
|
Text(
|
||||||
featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
|
featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
|
||||||
?? "all members"
|
?? "all members"
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.onChange(of: enableFeature.wrappedValue) { enabled in
|
.onChange(of: enableFeature.wrappedValue) { enabled in
|
||||||
if case .off = enabled {
|
if case .off = enabled {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct GroupWelcomeView: View {
|
struct GroupWelcomeView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@State var groupProfile: GroupProfile
|
@State var groupProfile: GroupProfile
|
||||||
@State var welcomeText: String
|
@State var welcomeText: String
|
||||||
@@ -57,7 +58,7 @@ struct GroupWelcomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func textPreview() -> some View {
|
private func textPreview() -> some View {
|
||||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
|
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
.frame(minHeight: 130, alignment: .topLeading)
|
.frame(minHeight: 130, alignment: .topLeading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ struct GroupWelcomeView: View {
|
|||||||
Group {
|
Group {
|
||||||
if welcomeText.isEmpty {
|
if welcomeText.isEmpty {
|
||||||
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
|
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
}
|
}
|
||||||
TextEditor(text: $welcomeText)
|
TextEditor(text: $welcomeText)
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//
|
||||||
|
// ReverseList.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Levitating Pineapple on 11/06/2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// A List, which displays it's items in reverse order - from bottom to top
|
||||||
|
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
||||||
|
|
||||||
|
let items: Array<Item>
|
||||||
|
|
||||||
|
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
||||||
|
|
||||||
|
/// Closure, that returns user interface for a given item
|
||||||
|
let content: (Item) -> Content
|
||||||
|
|
||||||
|
let loadPage: () -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> Controller {
|
||||||
|
Controller(representer: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||||
|
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||||
|
switch destination {
|
||||||
|
case .nextPage:
|
||||||
|
controller.scrollToNextPage()
|
||||||
|
case let .item(id):
|
||||||
|
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||||
|
case .bottom:
|
||||||
|
controller.scroll(to: .zero, position: .top)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
controller.update(items: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller, which hosts SwiftUI cells
|
||||||
|
class Controller: UITableViewController {
|
||||||
|
private enum Section { case main }
|
||||||
|
private let representer: ReverseList
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||||
|
private var itemCount: Int = .zero
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(representer: ReverseList) {
|
||||||
|
self.representer = representer
|
||||||
|
super.init(style: .plain)
|
||||||
|
|
||||||
|
// 1. Style
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.transform = .verticalFlip
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
|
||||||
|
// 2. Register cells
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
tableView.register(
|
||||||
|
UITableViewCell.self,
|
||||||
|
forCellReuseIdentifier: cellReuseId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tableView.register(
|
||||||
|
HostingCell<Content>.self,
|
||||||
|
forCellReuseIdentifier: cellReuseId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Configure data source
|
||||||
|
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
|
||||||
|
tableView: tableView
|
||||||
|
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||||
|
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
|
||||||
|
self.representer.loadPage()
|
||||||
|
}
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||||
|
.margins(.all, .zero)
|
||||||
|
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||||
|
} else {
|
||||||
|
if let cell = cell as? HostingCell<Content> {
|
||||||
|
cell.set(content: self.representer.content(item), parent: self)
|
||||||
|
} else {
|
||||||
|
fatalError("Unexpected Cell Type for: \(item)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell.transform = .verticalFlip
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
cell.backgroundColor = .clear
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. External state changes will require manual layout updates
|
||||||
|
NotificationCenter.default
|
||||||
|
.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(updateLayout),
|
||||||
|
name: notificationName,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
deinit { NotificationCenter.default.removeObserver(self) }
|
||||||
|
|
||||||
|
@objc private func updateLayout() {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
tableView.setNeedsLayout()
|
||||||
|
tableView.layoutIfNeeded()
|
||||||
|
} else {
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides keyboard, when user begins to scroll.
|
||||||
|
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
|
||||||
|
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
UIApplication.shared
|
||||||
|
.sendAction(
|
||||||
|
#selector(UIResponder.resignFirstResponder),
|
||||||
|
to: nil,
|
||||||
|
from: nil,
|
||||||
|
for: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls up
|
||||||
|
func scrollToNextPage() {
|
||||||
|
tableView.setContentOffset(
|
||||||
|
CGPoint(
|
||||||
|
x: tableView.contentOffset.x,
|
||||||
|
y: tableView.contentOffset.y + tableView.bounds.height
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
Task { representer.scrollState = .atDestination }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls to Item at index path
|
||||||
|
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||||
|
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||||
|
if let index {
|
||||||
|
var animated = false
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
animated = true
|
||||||
|
}
|
||||||
|
tableView.scrollToRow(
|
||||||
|
at: IndexPath(row: index, section: .zero),
|
||||||
|
at: position,
|
||||||
|
animated: animated
|
||||||
|
)
|
||||||
|
Task { representer.scrollState = .atDestination }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(items: Array<Item>) {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(items)
|
||||||
|
dataSource.defaultRowAnimation = .none
|
||||||
|
dataSource.apply(
|
||||||
|
snapshot,
|
||||||
|
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
|
||||||
|
)
|
||||||
|
itemCount = items.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||||
|
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
|
||||||
|
private final class HostingCell<Hosted: View>: UITableViewCell {
|
||||||
|
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||||
|
|
||||||
|
/// Updates content of the cell
|
||||||
|
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||||
|
func set(content: Hosted, parent: UIViewController) {
|
||||||
|
hostingController.view.backgroundColor = .clear
|
||||||
|
hostingController.rootView = content
|
||||||
|
if let hostingView = hostingController.view {
|
||||||
|
hostingView.invalidateIntrinsicContentSize()
|
||||||
|
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||||
|
if !contentView.subviews.contains(hostingController.view) {
|
||||||
|
contentView.addSubview(hostingController.view)
|
||||||
|
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
hostingView.leadingAnchor
|
||||||
|
.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
hostingView.trailingAnchor
|
||||||
|
.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
hostingView.topAnchor
|
||||||
|
.constraint(equalTo: contentView.topAnchor),
|
||||||
|
hostingView.bottomAnchor
|
||||||
|
.constraint(equalTo: contentView.bottomAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||||
|
} else {
|
||||||
|
fatalError("Hosting View not loaded \(hostingController)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
hostingController.rootView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages ``ReverseList`` scrolling
|
||||||
|
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
|
||||||
|
/// Represents Scroll State of ``ReverseList``
|
||||||
|
enum State: Equatable {
|
||||||
|
enum Destination: Equatable {
|
||||||
|
case nextPage
|
||||||
|
case item(Item.ID)
|
||||||
|
case bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
case scrollingTo(Destination)
|
||||||
|
case atDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var state: State = .atDestination
|
||||||
|
|
||||||
|
func scrollToNextPage() {
|
||||||
|
state = .scrollingTo(.nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToBottom() {
|
||||||
|
state = .scrollingTo(.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToItem(id: Item.ID) {
|
||||||
|
state = .scrollingTo(.item(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate let cellReuseId = "hostingCell"
|
||||||
|
|
||||||
|
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
|
||||||
|
|
||||||
|
fileprivate extension CGAffineTransform {
|
||||||
|
/// Transform that vertically flips the view, preserving it's location
|
||||||
|
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationCenter {
|
||||||
|
static func postReverseListNeedsLayout() {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: notificationName,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable animation on iOS 15
|
||||||
|
func withConditionalAnimation<Result>(
|
||||||
|
_ animation: Animation? = .default,
|
||||||
|
_ body: () throws -> Result
|
||||||
|
) rethrows -> Result {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
try withAnimation(animation, body)
|
||||||
|
} else {
|
||||||
|
try body()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct VerifyCodeView: View {
|
struct VerifyCodeView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var displayName: String
|
var displayName: String
|
||||||
@State var connectionCode: String?
|
@State var connectionCode: String?
|
||||||
@State var connectionVerified: Bool
|
@State var connectionVerified: Bool
|
||||||
@@ -30,7 +31,7 @@ struct VerifyCodeView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if connectionVerified {
|
if connectionVerified {
|
||||||
Image(systemName: "checkmark.shield")
|
Image(systemName: "checkmark.shield")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
Text("\(displayName) is verified")
|
Text("\(displayName) is verified")
|
||||||
} else {
|
} else {
|
||||||
Text("\(displayName) is not verified")
|
Text("\(displayName) is not verified")
|
||||||
@@ -66,6 +67,7 @@ struct VerifyCodeView: View {
|
|||||||
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
|
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.navigationTitle("Scan code")
|
.navigationTitle("Scan code")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
} label: {
|
} label: {
|
||||||
Label("Scan code", systemImage: "qrcode")
|
Label("Scan code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
@@ -122,5 +124,6 @@ struct VerifyCodeView: View {
|
|||||||
struct VerifyCodeView_Previews: PreviewProvider {
|
struct VerifyCodeView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
|
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
|
||||||
|
.environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ private let rowHeights: [DynamicTypeSize: CGFloat] = [
|
|||||||
|
|
||||||
struct ChatListNavLink: View {
|
struct ChatListNavLink: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State private var showContactRequestDialog = false
|
@State private var showContactRequestDialog = false
|
||||||
@@ -224,7 +225,7 @@ struct ChatListNavLink: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
|
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
|
||||||
}
|
}
|
||||||
.tint(chat.chatInfo.incognito ? .indigo : .accentColor)
|
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func markReadButton() -> some View {
|
@ViewBuilder private func markReadButton() -> some View {
|
||||||
@@ -234,14 +235,14 @@ struct ChatListNavLink: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Read", systemImage: "checkmark")
|
Label("Read", systemImage: "checkmark")
|
||||||
}
|
}
|
||||||
.tint(Color.accentColor)
|
.tint(theme.colors.primary)
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
Task { await markChatUnread(chat) }
|
Task { await markChatUnread(chat) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unread", systemImage: "circlebadge.fill")
|
Label("Unread", systemImage: "circlebadge.fill")
|
||||||
}
|
}
|
||||||
.tint(Color.accentColor)
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -306,7 +307,7 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||||
} label: { Label("Accept", systemImage: "checkmark") }
|
} label: { Label("Accept", systemImage: "checkmark") }
|
||||||
.tint(.accentColor)
|
.tint(theme.colors.primary)
|
||||||
Button {
|
Button {
|
||||||
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -346,13 +347,16 @@ struct ChatListNavLink: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Name", systemImage: "pencil")
|
Label("Name", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
.tint(.accentColor)
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: rowHeights[dynamicTypeSize])
|
||||||
.sheet(isPresented: $showContactConnectionInfo) {
|
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
Group {
|
||||||
ContactConnectionInfo(contactConnection: contactConnection)
|
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
ContactConnectionInfo(contactConnection: contactConnection)
|
||||||
|
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
@@ -467,7 +471,7 @@ struct ChatListNavLink: View {
|
|||||||
.padding(4)
|
.padding(4)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: rowHeights[dynamicTypeSize])
|
||||||
.onTapGesture { showInvalidJSON = true }
|
.onTapGesture { showInvalidJSON = true }
|
||||||
.sheet(isPresented: $showInvalidJSON) {
|
.appSheet(isPresented: $showInvalidJSON) {
|
||||||
invalidJSONView(json)
|
invalidJSONView(json)
|
||||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ChatListView: View {
|
struct ChatListView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@State private var searchMode = false
|
@State private var searchMode = false
|
||||||
@FocusState private var searchFocussed
|
@FocusState private var searchFocussed
|
||||||
@@ -86,6 +87,7 @@ struct ChatListView: View {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
.background(theme.colors.background)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarHidden(searchMode)
|
.navigationBarHidden(searchMode)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -115,9 +117,7 @@ struct ChatListView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("Chats")
|
Text("Chats")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
if chatModel.chats.count > 0 {
|
SubsStatusIndicator()
|
||||||
toggleFilterButton()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
@@ -131,15 +131,6 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleFilterButton() -> some View {
|
|
||||||
Button {
|
|
||||||
showUnreadAndFavorites = !showUnreadAndFavorites
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private var chatList: some View {
|
@ViewBuilder private var chatList: some View {
|
||||||
let cs = filteredChats()
|
let cs = filteredChats()
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -154,12 +145,14 @@ struct ChatListView: View {
|
|||||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||||
)
|
)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
ForEach(cs, id: \.viewId) { chat in
|
ForEach(cs, id: \.viewId) { chat in
|
||||||
ChatListNavLink(chat: chat)
|
ChatListNavLink(chat: chat)
|
||||||
.padding(.trailing, -16)
|
.padding(.trailing, -16)
|
||||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
.offset(x: -8)
|
.offset(x: -8)
|
||||||
}
|
}
|
||||||
@@ -171,7 +164,7 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||||
Text("No filtered chats").foregroundColor(.secondary)
|
Text("No filtered chats").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +172,7 @@ struct ChatListView: View {
|
|||||||
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
|
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
|
||||||
Circle()
|
Circle()
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func onboardingButtons() -> some View {
|
private func onboardingButtons() -> some View {
|
||||||
@@ -190,7 +183,7 @@ struct ChatListView: View {
|
|||||||
p.addLine(to: CGPoint(x: 0, y: 10))
|
p.addLine(to: CGPoint(x: 0, y: 10))
|
||||||
p.addLine(to: CGPoint(x: 8, y: 0))
|
p.addLine(to: CGPoint(x: 8, y: 0))
|
||||||
}
|
}
|
||||||
.fill(Color.accentColor)
|
.fill(theme.colors.primary)
|
||||||
.frame(width: 20, height: 10)
|
.frame(width: 20, height: 10)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
|
|
||||||
@@ -200,7 +193,7 @@ struct ChatListView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("You have no chats")
|
Text("You have no chats")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 6)
|
.padding(.trailing, 6)
|
||||||
@@ -213,16 +206,14 @@ struct ChatListView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
.background(Color.accentColor)
|
.background(theme.colors.primary)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func chatView() -> some View {
|
@ViewBuilder private func chatView() -> some View {
|
||||||
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
||||||
ChatView(chat: chat).onAppear {
|
ChatView(chat: chat)
|
||||||
loadChat(chat: chat)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,17 +265,87 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SubsStatusIndicator: View {
|
||||||
|
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
||||||
|
@State private var sess: ServerSessions = ServerSessions.newServerSessions
|
||||||
|
@State private var timer: Timer? = nil
|
||||||
|
@State private var timerCounter = 0
|
||||||
|
@State private var showServersSummary = false
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||||
|
|
||||||
|
// Constants for the intervals
|
||||||
|
let initialInterval: TimeInterval = 1.0
|
||||||
|
let regularInterval: TimeInterval = 3.0
|
||||||
|
let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
showServersSummary = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
|
||||||
|
if showSubscriptionPercentage {
|
||||||
|
SubscriptionStatusPercentageView(subs: subs, sess: sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
startInitialTimer()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showServersSummary) {
|
||||||
|
ServersSummaryView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startInitialTimer() {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
|
||||||
|
getServersSummary()
|
||||||
|
timerCounter += 1
|
||||||
|
// Switch to the regular timer after the initial phase
|
||||||
|
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
|
||||||
|
switchToRegularTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchToRegularTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
|
||||||
|
getServersSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getServersSummary() {
|
||||||
|
do {
|
||||||
|
let summ = try getAgentServersSummary()
|
||||||
|
(subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions)
|
||||||
|
} catch let error {
|
||||||
|
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatListSearchBar: View {
|
struct ChatListSearchBar: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var searchMode: Bool
|
@Binding var searchMode: Bool
|
||||||
@FocusState.Binding var searchFocussed: Bool
|
@FocusState.Binding var searchFocussed: Bool
|
||||||
@Binding var searchText: String
|
@Binding var searchText: String
|
||||||
@Binding var searchShowingSimplexLink: Bool
|
@Binding var searchShowingSimplexLink: Bool
|
||||||
@Binding var searchChatFilteredBySimplexLink: String?
|
@Binding var searchChatFilteredBySimplexLink: String?
|
||||||
@State private var ignoreSearchTextChange = false
|
@State private var ignoreSearchTextChange = false
|
||||||
@State private var showScanCodeSheet = false
|
|
||||||
@State private var alert: PlanAndConnectAlert?
|
@State private var alert: PlanAndConnectAlert?
|
||||||
@State private var sheet: PlanAndConnectActionSheet?
|
@State private var sheet: PlanAndConnectActionSheet?
|
||||||
|
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
@@ -292,7 +353,7 @@ struct ChatListSearchBar: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
TextField("Search or paste SimpleX link", text: $searchText)
|
TextField("Search or paste SimpleX link", text: $searchText)
|
||||||
.foregroundColor(searchShowingSimplexLink ? .secondary : .primary)
|
.foregroundColor(searchShowingSimplexLink ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
.disabled(searchShowingSimplexLink)
|
.disabled(searchShowingSimplexLink)
|
||||||
.focused($searchFocussed)
|
.focused($searchFocussed)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -301,48 +362,26 @@ struct ChatListSearchBar: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
}
|
}
|
||||||
} else if !searchFocussed {
|
|
||||||
HStack(spacing: 24) {
|
|
||||||
if m.pasteboardHasStrings {
|
|
||||||
Image(systemName: "doc")
|
|
||||||
.onTapGesture {
|
|
||||||
if let str = UIPasteboard.general.string {
|
|
||||||
searchText = str
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "qrcode")
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
.onTapGesture {
|
|
||||||
showScanCodeSheet = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.background(Color(.tertiarySystemFill))
|
.background(Color(.tertiarySystemFill))
|
||||||
.cornerRadius(10.0)
|
.cornerRadius(10.0)
|
||||||
|
|
||||||
if searchFocussed {
|
if searchFocussed {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
searchFocussed = false
|
searchFocussed = false
|
||||||
}
|
}
|
||||||
|
} else if m.chats.count > 0 {
|
||||||
|
toggleFilterButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showScanCodeSheet) {
|
|
||||||
NewChatView(selection: .connect, showQRCodeScanner: true)
|
|
||||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
|
|
||||||
}
|
|
||||||
.onChange(of: searchFocussed) { sf in
|
.onChange(of: searchFocussed) { sf in
|
||||||
withAnimation { searchMode = sf }
|
withAnimation { searchMode = sf }
|
||||||
}
|
}
|
||||||
@@ -376,6 +415,21 @@ struct ChatListSearchBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func toggleFilterButton() -> some View {
|
||||||
|
ZStack {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
|
||||||
|
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
|
||||||
|
.onTapGesture {
|
||||||
|
showUnreadAndFavorites = !showUnreadAndFavorites
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func connect(_ link: String) {
|
private func connect(_ link: String) {
|
||||||
planAndConnect(
|
planAndConnect(
|
||||||
link,
|
link,
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ChatPreviewView: View {
|
struct ChatPreviewView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var progressByTimeout: Bool
|
@Binding var progressByTimeout: Bool
|
||||||
@State var deleting: Bool = false
|
@State var deleting: Bool = false
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
|
||||||
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
||||||
@@ -36,7 +36,7 @@ struct ChatPreviewView: View {
|
|||||||
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
|
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.frame(minWidth: 60, alignment: .trailing)
|
.frame(minWidth: 60, alignment: .trailing)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
@@ -94,9 +94,9 @@ struct ChatPreviewView: View {
|
|||||||
case let .group(groupInfo):
|
case let .group(groupInfo):
|
||||||
let v = previewTitle(t)
|
let v = previewTitle(t)
|
||||||
switch (groupInfo.membership.memberStatus) {
|
switch (groupInfo.membership.memberStatus) {
|
||||||
case .memInvited: v.foregroundColor(deleting ? .secondary : chat.chatInfo.incognito ? .indigo : .accentColor)
|
case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
||||||
case .memAccepted: v.foregroundColor(.secondary)
|
case .memAccepted: v.foregroundColor(theme.colors.secondary)
|
||||||
default: if deleting { v.foregroundColor(.secondary) } else { v }
|
default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v }
|
||||||
}
|
}
|
||||||
default: previewTitle(t)
|
default: previewTitle(t)
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ struct ChatPreviewView: View {
|
|||||||
|
|
||||||
private var verifiedIcon: Text {
|
private var verifiedIcon: Text {
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.baselineOffset(1)
|
.baselineOffset(1)
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
}
|
}
|
||||||
@@ -133,11 +133,11 @@ struct ChatPreviewView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.frame(minWidth: 18, minHeight: 18)
|
.frame(minWidth: 18, minHeight: 18)
|
||||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? Color.accentColor : Color.secondary)
|
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||||
Image(systemName: "speaker.slash.fill")
|
Image(systemName: "speaker.slash.fill")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||||
Image(systemName: "star.fill")
|
Image(systemName: "star.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -151,9 +151,9 @@ struct ChatPreviewView: View {
|
|||||||
|
|
||||||
private func messageDraft(_ draft: ComposeState) -> Text {
|
private func messageDraft(_ draft: ComposeState) -> Text {
|
||||||
let msg = draft.message
|
let msg = draft.message
|
||||||
return image("rectangle.and.pencil.and.ellipsis", color: .accentColor)
|
return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
|
||||||
+ attachment()
|
+ attachment()
|
||||||
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false)
|
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
|
|
||||||
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
|
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
|
||||||
Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
|
Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
|
||||||
@@ -172,7 +172,7 @@ struct ChatPreviewView: View {
|
|||||||
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
||||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
||||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
||||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false)
|
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
|
|
||||||
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
|
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
|
||||||
// can be refactored into a single function if functions calling these are changed to return same type
|
// can be refactored into a single function if functions calling these are changed to return same type
|
||||||
@@ -206,7 +206,7 @@ struct ChatPreviewView: View {
|
|||||||
case let .direct(contact):
|
case let .direct(contact):
|
||||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||||
chatPreviewInfoText("Tap to Connect")
|
chatPreviewInfoText("Tap to Connect")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
} else if !contact.ready && contact.activeConn != nil {
|
} else if !contact.ready && contact.activeConn != nil {
|
||||||
if contact.nextSendGrpInv {
|
if contact.nextSendGrpInv {
|
||||||
chatPreviewInfoText("send direct message")
|
chatPreviewInfoText("send direct message")
|
||||||
@@ -257,38 +257,38 @@ struct ChatPreviewView: View {
|
|||||||
case let .direct(contact):
|
case let .direct(contact):
|
||||||
if contact.active && contact.activeConn != nil {
|
if contact.active && contact.activeConn != nil {
|
||||||
switch (chatModel.contactNetworkStatus(contact)) {
|
switch (chatModel.contactNetworkStatus(contact)) {
|
||||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
||||||
case .error:
|
case .error:
|
||||||
Image(systemName: "exclamationmark.circle")
|
Image(systemName: "exclamationmark.circle")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 17, height: 17)
|
.frame(width: 17, height: 17)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
default:
|
default:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
incognitoIcon(chat.chatInfo.incognito)
|
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
case .group:
|
case .group:
|
||||||
if progressByTimeout {
|
if progressByTimeout {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else {
|
} else {
|
||||||
incognitoIcon(chat.chatInfo.incognito)
|
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
incognitoIcon(chat.chatInfo.incognito)
|
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View {
|
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
|
||||||
if incognito {
|
if incognito {
|
||||||
Image(systemName: "theatermasks")
|
Image(systemName: "theatermasks")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
} else {
|
} else {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ContactConnectionInfo: View {
|
struct ContactConnectionInfo: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@State var contactConnection: PendingContactConnection
|
@State var contactConnection: PendingContactConnection
|
||||||
@State private var alert: CCInfoAlert?
|
@State private var alert: CCInfoAlert?
|
||||||
@@ -48,7 +49,7 @@ struct ContactConnectionInfo: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
if contactConnection.groupLinkId == nil {
|
if contactConnection.groupLinkId == nil {
|
||||||
settingsRow("pencil") {
|
settingsRow("pencil", color: theme.colors.secondary) {
|
||||||
TextField("Set contact name…", text: $localAlias)
|
TextField("Set contact name…", text: $localAlias)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
@@ -63,14 +64,15 @@ struct ContactConnectionInfo: View {
|
|||||||
let connReqInv = contactConnection.connReqInv {
|
let connReqInv = contactConnection.connReqInv {
|
||||||
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
|
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
|
||||||
incognitoEnabled()
|
incognitoEnabled()
|
||||||
shareLinkButton(connReqInv)
|
shareLinkButton(connReqInv, theme.colors.secondary)
|
||||||
oneTimeLinkLearnMoreButton()
|
oneTimeLinkLearnMoreButton(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
incognitoEnabled()
|
incognitoEnabled()
|
||||||
oneTimeLinkLearnMoreButton()
|
oneTimeLinkLearnMoreButton(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
sharedProfileInfo(contactConnection.incognito)
|
sharedProfileInfo(contactConnection.incognito)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -82,6 +84,7 @@ struct ContactConnectionInfo: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
if #available(iOS 16, *) {
|
if #available(iOS 16, *) {
|
||||||
v
|
v
|
||||||
} else {
|
} else {
|
||||||
@@ -149,7 +152,7 @@ struct ContactConnectionInfo: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("Incognito")
|
Text("Incognito")
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
@@ -164,23 +167,24 @@ struct ContactConnectionInfo: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shareLinkButton(_ connReqInvitation: String) -> some View {
|
private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View {
|
||||||
Button {
|
Button {
|
||||||
showShareSheet(items: [simplexChatLink(connReqInvitation)])
|
showShareSheet(items: [simplexChatLink(connReqInvitation)])
|
||||||
} label: {
|
} label: {
|
||||||
settingsRow("square.and.arrow.up") {
|
settingsRow("square.and.arrow.up", color: secondaryColor) {
|
||||||
Text("Share 1-time link")
|
Text("Share 1-time link")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func oneTimeLinkLearnMoreButton() -> some View {
|
private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AddContactLearnMore(showTitle: false)
|
AddContactLearnMore(showTitle: false)
|
||||||
.navigationTitle("One-time invitation link")
|
.navigationTitle("One-time invitation link")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
settingsRow("info.circle") {
|
settingsRow("info.circle", color: secondaryColor) {
|
||||||
Text("Learn more")
|
Text("Learn more")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
|||||||
struct ContactConnectionView: View {
|
struct ContactConnectionView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@State private var localAlias = ""
|
@State private var localAlias = ""
|
||||||
@FocusState private var aliasTextFieldFocused: Bool
|
@FocusState private var aliasTextFieldFocused: Bool
|
||||||
@State private var showContactConnectionInfo = false
|
@State private var showContactConnectionInfo = false
|
||||||
@@ -29,7 +30,7 @@ struct ContactConnectionView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
.foregroundColor(Color(uiColor: .secondarySystemBackground))
|
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
|
||||||
.onTapGesture { showContactConnectionInfo = true }
|
.onTapGesture { showContactConnectionInfo = true }
|
||||||
}
|
}
|
||||||
.frame(width: 63, height: 63)
|
.frame(width: 63, height: 63)
|
||||||
@@ -41,7 +42,7 @@ struct ContactConnectionView: View {
|
|||||||
.font(.title3)
|
.font(.title3)
|
||||||
.bold()
|
.bold()
|
||||||
.allowsTightening(false)
|
.allowsTightening(false)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
.padding(.bottom, 0.5)
|
.padding(.bottom, 0.5)
|
||||||
@@ -54,14 +55,14 @@ struct ContactConnectionView: View {
|
|||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.frame(minWidth: 60, alignment: .trailing)
|
.frame(minWidth: 60, alignment: .trailing)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
|
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Text(contactConnection.description)
|
Text(contactConnection.description)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
incognitoIcon(contactConnection.incognito)
|
incognitoIcon(contactConnection.incognito, theme.colors.secondary)
|
||||||
.padding(.top, 26)
|
.padding(.top, 26)
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ContactRequestView: View {
|
struct ContactRequestView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var contactRequest: UserContactRequest
|
var contactRequest: UserContactRequest
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ struct ContactRequestView: View {
|
|||||||
Text(contactRequest.chatViewName)
|
Text(contactRequest.chatViewName)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
.frame(alignment: .topLeading)
|
.frame(alignment: .topLeading)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -32,7 +33,7 @@ struct ContactRequestView: View {
|
|||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.frame(minWidth: 60, alignment: .trailing)
|
.frame(minWidth: 60, alignment: .trailing)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
//
|
||||||
|
// ServersSummaryView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by spaced4ndy on 25.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct ServersSummaryView: View {
|
||||||
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@State private var serversSummary: PresentedServersSummary? = nil
|
||||||
|
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
|
||||||
|
@State private var selectedServerType: PresentedServerType = .smp
|
||||||
|
@State private var selectedSMPServer: String? = nil
|
||||||
|
@State private var selectedXFTPServer: String? = nil
|
||||||
|
@State private var timer: Timer? = nil
|
||||||
|
@State private var alert: SomeAlert?
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||||
|
|
||||||
|
enum PresentedUserCategory {
|
||||||
|
case currentUser
|
||||||
|
case allUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PresentedServerType {
|
||||||
|
case smp
|
||||||
|
case xftp
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
viewBody()
|
||||||
|
.navigationTitle("Servers info")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
shareButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count == 1 {
|
||||||
|
selectedUserCategory = .currentUser
|
||||||
|
}
|
||||||
|
getServersSummary()
|
||||||
|
startTimer()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
.alert(item: $alert) { $0.alert }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||||
|
getServersSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareButton() -> some View {
|
||||||
|
Button {
|
||||||
|
if let serversSummary = serversSummary {
|
||||||
|
showShareSheet(items: [encodePrettyPrinted(serversSummary)])
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.disabled(serversSummary == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encodePrettyPrinted<T: Encodable>(_ value: T) -> String {
|
||||||
|
let encoder = jsonEncoder
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
let data = try! encoder.encode(value)
|
||||||
|
return String(decoding: data, as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func viewBody() -> some View {
|
||||||
|
if let summ = serversSummary {
|
||||||
|
List {
|
||||||
|
Group {
|
||||||
|
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||||
|
Picker("User selection", selection: $selectedUserCategory) {
|
||||||
|
Text("All users").tag(PresentedUserCategory.allUsers)
|
||||||
|
Text("Current user").tag(PresentedUserCategory.currentUser)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Server type", selection: $selectedServerType) {
|
||||||
|
Text("Messages").tag(PresentedServerType.smp)
|
||||||
|
Text("Files").tag(PresentedServerType.xftp)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
|
||||||
|
switch (selectedUserCategory, selectedServerType) {
|
||||||
|
case (.allUsers, .smp):
|
||||||
|
let smpSumm = summ.allUsersSMP
|
||||||
|
let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers)
|
||||||
|
|
||||||
|
SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||||
|
|
||||||
|
smpSubsSection(totals)
|
||||||
|
|
||||||
|
if curr.count > 0 {
|
||||||
|
smpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||||
|
}
|
||||||
|
if prev.count > 0 {
|
||||||
|
smpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||||
|
}
|
||||||
|
if prox.count > 0 {
|
||||||
|
smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.")
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSessionsView(sess: totals.sessions)
|
||||||
|
case (.currentUser, .smp):
|
||||||
|
let smpSumm = summ.currentUserSMP
|
||||||
|
let (totals, curr, prev, prox) = (smpSumm.smpTotals, smpSumm.currentlyUsedSMPServers, smpSumm.previouslyUsedSMPServers, smpSumm.onlyProxiedSMPServers)
|
||||||
|
|
||||||
|
SMPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||||
|
|
||||||
|
smpSubsSection(totals)
|
||||||
|
|
||||||
|
if curr.count > 0 {
|
||||||
|
smpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||||
|
}
|
||||||
|
if prev.count > 0 {
|
||||||
|
smpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||||
|
}
|
||||||
|
if prox.count > 0 {
|
||||||
|
smpServersListView(prox, summ.statsStartedAt, "Proxied servers", "You are not connected to these servers. Private routing is used to deliver messages to them.")
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSessionsView(sess: totals.sessions)
|
||||||
|
case (.allUsers, .xftp):
|
||||||
|
let xftpSumm = summ.allUsersXFTP
|
||||||
|
let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers)
|
||||||
|
|
||||||
|
XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||||
|
|
||||||
|
if curr.count > 0 {
|
||||||
|
xftpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||||
|
}
|
||||||
|
if prev.count > 0 {
|
||||||
|
xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSessionsView(sess: totals.sessions)
|
||||||
|
case (.currentUser, .xftp):
|
||||||
|
let xftpSumm = summ.currentUserXFTP
|
||||||
|
let (totals, curr, prev) = (xftpSumm.xftpTotals, xftpSumm.currentlyUsedXFTPServers, xftpSumm.previouslyUsedXFTPServers)
|
||||||
|
|
||||||
|
XFTPStatsView(stats: totals.stats, statsStartedAt: summ.statsStartedAt)
|
||||||
|
|
||||||
|
if curr.count > 0 {
|
||||||
|
xftpServersListView(curr, summ.statsStartedAt, "Connected servers")
|
||||||
|
}
|
||||||
|
if prev.count > 0 {
|
||||||
|
xftpServersListView(prev, summ.statsStartedAt, "Previously connected servers")
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerSessionsView(sess: totals.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
reconnectAllButton()
|
||||||
|
resetStatsButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No info, try to reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smpSubsSection(_ totals: SMPTotals) -> some View {
|
||||||
|
Section {
|
||||||
|
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
|
||||||
|
infoRow("Total", numOrDash(totals.subs.total))
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Message subscriptions")
|
||||||
|
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
|
||||||
|
if showSubscriptionPercentage {
|
||||||
|
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconnectAllButton() -> some View {
|
||||||
|
Button {
|
||||||
|
alert = SomeAlert(
|
||||||
|
alert: Alert(
|
||||||
|
title: Text("Reconnect all servers?"),
|
||||||
|
message: Text("Reconnect all connected servers to force message delivery. It uses additional traffic."),
|
||||||
|
primaryButton: .default(Text("Ok")) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await reconnectAllServers()
|
||||||
|
} catch let error {
|
||||||
|
alert = SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Error reconnecting servers",
|
||||||
|
message: "\(responseError(error))"
|
||||||
|
),
|
||||||
|
id: "error reconnecting servers"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
),
|
||||||
|
id: "reconnect servers question"
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Text("Reconnect all servers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func smpServersListView(
|
||||||
|
_ servers: [SMPServerSummary],
|
||||||
|
_ statsStartedAt: Date,
|
||||||
|
_ header: LocalizedStringKey? = nil,
|
||||||
|
_ footer: LocalizedStringKey? = nil
|
||||||
|
) -> some View {
|
||||||
|
let sortedServers = servers.sorted {
|
||||||
|
$0.hasSubs == $1.hasSubs
|
||||||
|
? serverAddress($0.smpServer) < serverAddress($1.smpServer)
|
||||||
|
: $0.hasSubs && !$1.hasSubs
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ForEach(sortedServers) { server in
|
||||||
|
smpServerView(server, statsStartedAt)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if let header = header {
|
||||||
|
Text(header)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if let footer = footer {
|
||||||
|
Text(footer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smpServerView(_ srvSumm: SMPServerSummary, _ statsStartedAt: Date) -> some View {
|
||||||
|
NavigationLink(tag: srvSumm.id, selection: $selectedSMPServer) {
|
||||||
|
SMPServerSummaryView(
|
||||||
|
summary: srvSumm,
|
||||||
|
statsStartedAt: statsStartedAt
|
||||||
|
)
|
||||||
|
.navigationBarTitle("SMP server")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(serverAddress(srvSumm.smpServer))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let subs = srvSumm.subs {
|
||||||
|
Spacer()
|
||||||
|
if showSubscriptionPercentage {
|
||||||
|
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||||
|
}
|
||||||
|
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||||
|
} else if let sess = srvSumm.sessions {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.circle")
|
||||||
|
.symbolRenderingMode(.palette)
|
||||||
|
.foregroundStyle(sessIconColor(sess), Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func serverAddress(_ server: String) -> String {
|
||||||
|
parseServerAddress(server)?.hostnames.first ?? server
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sessIconColor(_ sess: ServerSessions) -> Color {
|
||||||
|
let online = m.networkInfo.online
|
||||||
|
return (
|
||||||
|
online && sess.ssConnected > 0
|
||||||
|
? sessionActiveColor
|
||||||
|
: Color(uiColor: .tertiaryLabel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sessionActiveColor: Color {
|
||||||
|
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||||
|
return onionHosts == .require ? .indigo : .accentColor
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func xftpServersListView(
|
||||||
|
_ servers: [XFTPServerSummary],
|
||||||
|
_ statsStartedAt: Date,
|
||||||
|
_ header: LocalizedStringKey? = nil,
|
||||||
|
_ footer: LocalizedStringKey? = nil
|
||||||
|
) -> some View {
|
||||||
|
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
|
||||||
|
Section {
|
||||||
|
ForEach(sortedServers) { server in
|
||||||
|
xftpServerView(server, statsStartedAt)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if let header = header {
|
||||||
|
Text(header)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if let footer = footer {
|
||||||
|
Text(footer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xftpServerView(_ srvSumm: XFTPServerSummary, _ statsStartedAt: Date) -> some View {
|
||||||
|
NavigationLink(tag: srvSumm.id, selection: $selectedXFTPServer) {
|
||||||
|
XFTPServerSummaryView(
|
||||||
|
summary: srvSumm,
|
||||||
|
statsStartedAt: statsStartedAt
|
||||||
|
)
|
||||||
|
.navigationBarTitle("XFTP server")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(serverAddress(srvSumm.xftpServer))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let inProgressIcon = inProgressIcon(srvSumm) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: inProgressIcon)
|
||||||
|
.symbolRenderingMode(.palette)
|
||||||
|
.foregroundStyle(sessionActiveColor, Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inProgressIcon(_ srvSumm: XFTPServerSummary) -> String? {
|
||||||
|
switch (srvSumm.rcvInProgress, srvSumm.sndInProgress, srvSumm.delInProgress) {
|
||||||
|
case (false, false, false): nil
|
||||||
|
case (true, false, false): "arrow.down.circle"
|
||||||
|
case (false, true, false): "arrow.up.circle"
|
||||||
|
case (false, false, true): "trash.circle"
|
||||||
|
default: "arrow.up.arrow.down.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetStatsButton() -> some View {
|
||||||
|
Button {
|
||||||
|
alert = SomeAlert(
|
||||||
|
alert: Alert(
|
||||||
|
title: Text("Reset all statistics?"),
|
||||||
|
message: Text("Servers statistics will be reset - this cannot be undone!"),
|
||||||
|
primaryButton: .destructive(Text("Reset")) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await resetAgentServersStats()
|
||||||
|
getServersSummary()
|
||||||
|
} catch let error {
|
||||||
|
alert = SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Error resetting statistics",
|
||||||
|
message: "\(responseError(error))"
|
||||||
|
),
|
||||||
|
id: "error resetting statistics"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
),
|
||||||
|
id: "reset statistics question"
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Text("Reset all statistics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getServersSummary() {
|
||||||
|
do {
|
||||||
|
serversSummary = try getAgentServersSummary()
|
||||||
|
} catch let error {
|
||||||
|
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubscriptionStatusIndicatorView: View {
|
||||||
|
@EnvironmentObject var m: ChatModel
|
||||||
|
var subs: SMPServerSubs
|
||||||
|
var sess: ServerSessions
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||||
|
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
|
||||||
|
.foregroundColor(color)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "dot.radiowaves.up.forward")
|
||||||
|
.foregroundColor(color.opacity(opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubscriptionStatusPercentageView: View {
|
||||||
|
@EnvironmentObject var m: ChatModel
|
||||||
|
var subs: SMPServerSubs
|
||||||
|
var sess: ServerSessions
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||||
|
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||||
|
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
|
||||||
|
func roundedToQuarter(_ n: Double) -> Double {
|
||||||
|
n >= 1 ? 1
|
||||||
|
: n <= 0 ? 0
|
||||||
|
: (n * 4).rounded() / 4
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
|
||||||
|
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
|
||||||
|
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
|
||||||
|
|
||||||
|
return online && subs.total > 0
|
||||||
|
? (
|
||||||
|
subs.ssActive == 0
|
||||||
|
? (
|
||||||
|
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||||
|
)
|
||||||
|
: ( // ssActive > 0
|
||||||
|
sess.ssConnected == 0
|
||||||
|
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
|
||||||
|
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: noConnColorAndPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SMPServerSummaryView: View {
|
||||||
|
var summary: SMPServerSummary
|
||||||
|
var statsStartedAt: Date
|
||||||
|
@State private var alert: SomeAlert?
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Server address") {
|
||||||
|
Text(summary.smpServer)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
if summary.known == true {
|
||||||
|
NavigationLink {
|
||||||
|
ProtocolServersView(serverProtocol: .smp)
|
||||||
|
.navigationTitle("Your SMP servers")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
} label: {
|
||||||
|
Text("Open server settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stats = summary.stats {
|
||||||
|
SMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subs = summary.subs {
|
||||||
|
smpSubsSection(subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sess = summary.sessions {
|
||||||
|
ServerSessionsView(sess: sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(item: $alert) { $0.alert }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
|
||||||
|
Section {
|
||||||
|
infoRow("Connections subscribed", numOrDash(subs.ssActive))
|
||||||
|
infoRow("Pending", numOrDash(subs.ssPending))
|
||||||
|
infoRow("Total", numOrDash(subs.total))
|
||||||
|
reconnectButton()
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Message subscriptions")
|
||||||
|
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
|
||||||
|
if showSubscriptionPercentage {
|
||||||
|
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconnectButton() -> some View {
|
||||||
|
Button {
|
||||||
|
alert = SomeAlert(
|
||||||
|
alert: Alert(
|
||||||
|
title: Text("Reconnect server?"),
|
||||||
|
message: Text("Reconnect server to force message delivery. It uses additional traffic."),
|
||||||
|
primaryButton: .default(Text("Ok")) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await reconnectServer(smpServer: summary.smpServer)
|
||||||
|
} catch let error {
|
||||||
|
alert = SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Error reconnecting server",
|
||||||
|
message: "\(responseError(error))"
|
||||||
|
),
|
||||||
|
id: "error reconnecting server"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
),
|
||||||
|
id: "reconnect server question"
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Text("Reconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerSessionsView: View {
|
||||||
|
var sess: ServerSessions
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Transport sessions") {
|
||||||
|
infoRow("Connected", numOrDash(sess.ssConnected))
|
||||||
|
infoRow("Errors", numOrDash(sess.ssErrors))
|
||||||
|
infoRow("Connecting", numOrDash(sess.ssConnecting))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SMPStatsView: View {
|
||||||
|
var stats: AgentSMPServerStatsData
|
||||||
|
var statsStartedAt: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
infoRow("Messages sent", numOrDash(stats._sentDirect + stats._sentViaProxy))
|
||||||
|
infoRow("Messages received", numOrDash(stats._recvMsgs))
|
||||||
|
NavigationLink {
|
||||||
|
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||||
|
.navigationTitle("Detailed statistics")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
} label: {
|
||||||
|
Text("Details")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Statistics")
|
||||||
|
} footer: {
|
||||||
|
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func numOrDash(_ n: Int) -> String {
|
||||||
|
n == 0 ? "-" : "\(n)"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DetailedSMPStatsView: View {
|
||||||
|
var stats: AgentSMPServerStatsData
|
||||||
|
var statsStartedAt: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Sent messages") {
|
||||||
|
infoRow("Sent total", numOrDash(stats._sentDirect + stats._sentViaProxy))
|
||||||
|
infoRowTwoValues("Sent directly", "attempts", stats._sentDirect, stats._sentDirectAttempts)
|
||||||
|
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
|
||||||
|
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
|
||||||
|
Text("Send errors")
|
||||||
|
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._sentAuthErrs)).padding(.leading, 24)
|
||||||
|
infoRow(Text(verbatim: "QUOTA"), numOrDash(stats._sentQuotaErrs)).padding(.leading, 24)
|
||||||
|
infoRow("expired", numOrDash(stats._sentExpiredErrs)).padding(.leading, 24)
|
||||||
|
infoRow("other", numOrDash(stats._sentOtherErrs)).padding(.leading, 24)
|
||||||
|
}
|
||||||
|
Section("Received messages") {
|
||||||
|
infoRow("Received total", numOrDash(stats._recvMsgs))
|
||||||
|
Text("Receive errors")
|
||||||
|
infoRow("duplicates", numOrDash(stats._recvDuplicates)).padding(.leading, 24)
|
||||||
|
infoRow("decryption errors", numOrDash(stats._recvCryptoErrs)).padding(.leading, 24)
|
||||||
|
infoRow("other errors", numOrDash(stats._recvErrs)).padding(.leading, 24)
|
||||||
|
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
|
||||||
|
Text("Acknowledgement errors")
|
||||||
|
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
|
||||||
|
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
infoRow("Created", numOrDash(stats._connCreated))
|
||||||
|
infoRow("Secured", numOrDash(stats._connCreated))
|
||||||
|
infoRow("Completed", numOrDash(stats._connCompleted))
|
||||||
|
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
|
||||||
|
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
|
||||||
|
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
|
||||||
|
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
|
||||||
|
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
|
||||||
|
} header: {
|
||||||
|
Text("Connections")
|
||||||
|
} footer: {
|
||||||
|
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(title) + Text(verbatim: " / ").font(.caption2) + Text(title2).font(.caption2)
|
||||||
|
Spacer()
|
||||||
|
Group {
|
||||||
|
if value == 0 && value2 == 0 {
|
||||||
|
Text(verbatim: "-")
|
||||||
|
} else {
|
||||||
|
Text(numOrDash(value)) + Text(verbatim: " / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct XFTPServerSummaryView: View {
|
||||||
|
var summary: XFTPServerSummary
|
||||||
|
var statsStartedAt: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Server address") {
|
||||||
|
Text(summary.xftpServer)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
if summary.known == true {
|
||||||
|
NavigationLink {
|
||||||
|
ProtocolServersView(serverProtocol: .xftp)
|
||||||
|
.navigationTitle("Your XFTP servers")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
} label: {
|
||||||
|
Text("Open server settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stats = summary.stats {
|
||||||
|
XFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sess = summary.sessions {
|
||||||
|
ServerSessionsView(sess: sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct XFTPStatsView: View {
|
||||||
|
var stats: AgentXFTPServerStatsData
|
||||||
|
var statsStartedAt: Date
|
||||||
|
@State private var expanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
infoRow("Uploaded", prettySize(stats._uploadsSize))
|
||||||
|
infoRow("Downloaded", prettySize(stats._downloadsSize))
|
||||||
|
NavigationLink {
|
||||||
|
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||||
|
.navigationTitle("Detailed statistics")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
} label: {
|
||||||
|
Text("Details")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Statistics")
|
||||||
|
} footer: {
|
||||||
|
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prettySize(_ sizeInKB: Int64) -> String {
|
||||||
|
let kb: Int64 = 1024
|
||||||
|
return sizeInKB == 0 ? "-" : ByteCountFormatter.string(fromByteCount: sizeInKB * kb, countStyle: .binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DetailedXFTPStatsView: View {
|
||||||
|
var stats: AgentXFTPServerStatsData
|
||||||
|
var statsStartedAt: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Uploaded files") {
|
||||||
|
infoRow("Size", prettySize(stats._uploadsSize))
|
||||||
|
infoRowTwoValues("Chunks uploaded", "attempts", stats._uploads, stats._uploadAttempts)
|
||||||
|
infoRow("Upload errors", numOrDash(stats._uploadErrs))
|
||||||
|
infoRowTwoValues("Chunks deleted", "attempts", stats._deletions, stats._deleteAttempts)
|
||||||
|
infoRow("Deletion errors", numOrDash(stats._deleteErrs))
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
infoRow("Size", prettySize(stats._downloadsSize))
|
||||||
|
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
|
||||||
|
Text("Download errors")
|
||||||
|
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._downloadAuthErrs)).padding(.leading, 24)
|
||||||
|
infoRow("other", numOrDash(stats._downloadErrs)).padding(.leading, 24)
|
||||||
|
} header: {
|
||||||
|
Text("Downloaded files")
|
||||||
|
} footer: {
|
||||||
|
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ServersSummaryView()
|
||||||
|
}
|
||||||
@@ -6,13 +6,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
private let fillColorDark = Color(uiColor: UIColor(red: 0.11, green: 0.11, blue: 0.11, alpha: 255))
|
|
||||||
private let fillColorLight = Color(uiColor: UIColor(red: 0.99, green: 0.99, blue: 0.99, alpha: 255))
|
|
||||||
|
|
||||||
struct UserPicker: View {
|
struct UserPicker: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@Binding var showConnectDesktop: Bool
|
@Binding var showConnectDesktop: Bool
|
||||||
@Binding var userPickerVisible: Bool
|
@Binding var userPickerVisible: Bool
|
||||||
@@ -21,9 +18,6 @@ struct UserPicker: View {
|
|||||||
private let menuButtonHeight: CGFloat = 68
|
private let menuButtonHeight: CGFloat = 68
|
||||||
@State var chatViewNameWidth: CGFloat = 0
|
@State var chatViewNameWidth: CGFloat = 0
|
||||||
|
|
||||||
var fillColor: Color {
|
|
||||||
colorScheme == .dark ? fillColorDark : fillColorLight
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -82,7 +76,7 @@ struct UserPicker: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.background(
|
.background(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(fillColor)
|
.fill(theme.colors.surface)
|
||||||
.cornerRadius(16)
|
.cornerRadius(16)
|
||||||
.shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
|
.shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
|
||||||
)
|
)
|
||||||
@@ -131,13 +125,13 @@ struct UserPicker: View {
|
|||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
Text(user.chatViewName)
|
Text(user.chatViewName)
|
||||||
.fontWeight(user.activeUser ? .medium : .regular)
|
.fontWeight(user.activeUser ? .medium : .regular)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
Spacer()
|
Spacer()
|
||||||
if user.activeUser {
|
if user.activeUser {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
} else if u.unreadCount > 0 {
|
} else if u.unreadCount > 0 {
|
||||||
unreadCounter(u.unreadCount, color: user.showNtfs ? .accentColor : .secondary)
|
unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary)
|
||||||
} else if !user.showNtfs {
|
} else if !user.showNtfs {
|
||||||
Image(systemName: "speaker.slash")
|
Image(systemName: "speaker.slash")
|
||||||
}
|
}
|
||||||
@@ -145,7 +139,7 @@ struct UserPicker: View {
|
|||||||
.padding(.trailing)
|
.padding(.trailing)
|
||||||
.padding([.leading, .vertical], 12)
|
.padding([.leading, .vertical], 12)
|
||||||
})
|
})
|
||||||
.buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill)))
|
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
|
private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
|
||||||
@@ -156,13 +150,13 @@ struct UserPicker: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.symbolRenderingMode(.monochrome)
|
.symbolRenderingMode(.monochrome)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 22)
|
.padding(.vertical, 22)
|
||||||
.frame(height: menuButtonHeight)
|
.frame(height: menuButtonHeight)
|
||||||
}
|
}
|
||||||
.buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill)))
|
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ChatArchiveView: View {
|
struct ChatArchiveView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var archiveName: String
|
var archiveName: String
|
||||||
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
|
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
|
||||||
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
|
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
|
||||||
@@ -20,14 +21,14 @@ struct ChatArchiveView: View {
|
|||||||
let fileTs = chatArchiveTimeDefault.get()
|
let fileTs = chatArchiveTimeDefault.get()
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
settingsRow("square.and.arrow.up") {
|
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
|
||||||
Button {
|
Button {
|
||||||
showShareSheet(items: [fileUrl])
|
showShareSheet(items: [fileUrl])
|
||||||
} label: {
|
} label: {
|
||||||
Text("Save archive")
|
Text("Save archive")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settingsRow("trash") {
|
settingsRow("trash", color: theme.colors.secondary) {
|
||||||
Button {
|
Button {
|
||||||
showDeleteAlert = true
|
showDeleteAlert = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -36,8 +37,10 @@ struct ChatArchiveView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Chat archive")
|
Text("Chat archive")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Created on \(fileTs)")
|
Text("Created on \(fileTs)")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(isPresented: $showDeleteAlert) {
|
.alert(isPresented: $showDeleteAlert) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ enum DatabaseEncryptionAlert: Identifiable {
|
|||||||
|
|
||||||
struct DatabaseEncryptionView: View {
|
struct DatabaseEncryptionView: View {
|
||||||
@EnvironmentObject private var m: ChatModel
|
@EnvironmentObject private var m: ChatModel
|
||||||
|
@EnvironmentObject private var theme: AppTheme
|
||||||
@Binding var useKeychain: Bool
|
@Binding var useKeychain: Bool
|
||||||
var migration: Bool
|
var migration: Bool
|
||||||
@State private var alert: DatabaseEncryptionAlert? = nil
|
@State private var alert: DatabaseEncryptionAlert? = nil
|
||||||
@@ -63,7 +64,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
|
|
||||||
private func databaseEncryptionView() -> some View {
|
private func databaseEncryptionView() -> some View {
|
||||||
Section {
|
Section {
|
||||||
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) {
|
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : theme.colors.secondary) {
|
||||||
Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle)
|
Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle)
|
||||||
.onChange(of: useKeychainToggle) { _ in
|
.onChange(of: useKeychainToggle) { _ in
|
||||||
if useKeychainToggle {
|
if useKeychainToggle {
|
||||||
@@ -85,7 +86,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
|
PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
|
||||||
PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
|
PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
|
||||||
|
|
||||||
settingsRow("lock.rotation") {
|
settingsRow("lock.rotation", color: theme.colors.secondary) {
|
||||||
Button(migration ? "Set passphrase" : "Update database passphrase") {
|
Button(migration ? "Set passphrase" : "Update database passphrase") {
|
||||||
alert = currentKey == ""
|
alert = currentKey == ""
|
||||||
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
|
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
|
||||||
@@ -102,6 +103,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
)
|
)
|
||||||
} header: {
|
} header: {
|
||||||
Text(migration ? "Database passphrase" : "")
|
Text(migration ? "Database passphrase" : "")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if m.chatDbEncrypted == false {
|
if m.chatDbEncrypted == false {
|
||||||
@@ -125,6 +127,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
}
|
}
|
||||||
@@ -277,6 +280,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
|
|
||||||
|
|
||||||
struct PassphraseField: View {
|
struct PassphraseField: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var key: String
|
@Binding var key: String
|
||||||
var placeholder: LocalizedStringKey
|
var placeholder: LocalizedStringKey
|
||||||
var valid: Bool
|
var valid: Bool
|
||||||
@@ -287,7 +291,7 @@ struct PassphraseField: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
let iconColor = valid
|
let iconColor = valid
|
||||||
? (showStrength && key != "" ? PassphraseStrength(passphrase: key).color : .secondary)
|
? (showStrength && key != "" ? PassphraseStrength(passphrase: key).color : theme.colors.secondary)
|
||||||
: .red
|
: .red
|
||||||
Image(systemName: valid ? (showKey ? "eye.slash" : "eye") : "exclamationmark.circle")
|
Image(systemName: valid ? (showKey ? "eye.slash" : "eye") : "exclamationmark.circle")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ enum DatabaseAlert: Identifiable {
|
|||||||
|
|
||||||
struct DatabaseView: View {
|
struct DatabaseView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@State private var runChat = false
|
@State private var runChat = false
|
||||||
@State private var alert: DatabaseAlert? = nil
|
@State private var alert: DatabaseAlert? = nil
|
||||||
@@ -82,8 +83,10 @@ struct DatabaseView: View {
|
|||||||
.disabled(stopped || progressIndicator)
|
.disabled(stopped || progressIndicator)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Messages")
|
Text("Messages")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("This setting applies to messages in your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
Text("This setting applies to messages in your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -105,24 +108,27 @@ struct DatabaseView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Run chat")
|
Text("Run chat")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
if case .documents = dbContainer {
|
if case .documents = dbContainer {
|
||||||
Text("Database will be migrated when the app restarts")
|
Text("Database will be migrated when the app restarts")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
let unencrypted = m.chatDbEncrypted == false
|
let unencrypted = m.chatDbEncrypted == false
|
||||||
let color: Color = unencrypted ? .orange : .secondary
|
let color: Color = unencrypted ? .orange : theme.colors.secondary
|
||||||
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
|
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
|
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
|
||||||
.navigationTitle("Database passphrase")
|
.navigationTitle("Database passphrase")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("Database passphrase")
|
Text("Database passphrase")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settingsRow("square.and.arrow.up") {
|
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
|
||||||
Button("Export database") {
|
Button("Export database") {
|
||||||
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
|
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
|
||||||
alert = .exportProhibited
|
alert = .exportProhibited
|
||||||
@@ -131,7 +137,7 @@ struct DatabaseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settingsRow("square.and.arrow.down") {
|
settingsRow("square.and.arrow.down", color: theme.colors.secondary) {
|
||||||
Button("Import database", role: .destructive) {
|
Button("Import database", role: .destructive) {
|
||||||
showFileImporter = true
|
showFileImporter = true
|
||||||
}
|
}
|
||||||
@@ -140,34 +146,37 @@ struct DatabaseView: View {
|
|||||||
let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
|
let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
|
||||||
? "Old database archive"
|
? "Old database archive"
|
||||||
: "New database archive"
|
: "New database archive"
|
||||||
settingsRow("archivebox") {
|
settingsRow("archivebox", color: theme.colors.secondary) {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ChatArchiveView(archiveName: archiveName)
|
ChatArchiveView(archiveName: archiveName)
|
||||||
.navigationTitle(title)
|
.navigationTitle(title)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text(title)
|
Text(title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settingsRow("trash.slash") {
|
settingsRow("trash.slash", color: theme.colors.secondary) {
|
||||||
Button("Delete database", role: .destructive) {
|
Button("Delete database", role: .destructive) {
|
||||||
alert = .deleteChat
|
alert = .deleteChat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Chat database")
|
Text("Chat database")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(
|
Text(
|
||||||
stopped
|
stopped
|
||||||
? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
|
? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
|
||||||
: "Stop chat to enable database actions"
|
: "Stop chat to enable database actions"
|
||||||
)
|
)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.disabled(!stopped)
|
.disabled(!stopped)
|
||||||
|
|
||||||
if case .group = dbContainer, legacyDatabase {
|
if case .group = dbContainer, legacyDatabase {
|
||||||
Section("Old database") {
|
Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) {
|
||||||
settingsRow("trash") {
|
settingsRow("trash", color: theme.colors.secondary) {
|
||||||
Button("Delete old database") {
|
Button("Delete old database") {
|
||||||
alert = .deleteLegacyDatabase
|
alert = .deleteLegacyDatabase
|
||||||
}
|
}
|
||||||
@@ -182,12 +191,15 @@ struct DatabaseView: View {
|
|||||||
.disabled(!stopped || appFilesCountAndSize?.0 == 0)
|
.disabled(!stopped || appFilesCountAndSize?.0 == 0)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Files & media")
|
Text("Files & media")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
if let (fileCount, size) = appFilesCountAndSize {
|
if let (fileCount, size) = appFilesCountAndSize {
|
||||||
if fileCount == 0 {
|
if fileCount == 0 {
|
||||||
Text("No received or sent files")
|
Text("No received or sent files")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))")
|
Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,6 +367,7 @@ struct DatabaseView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await apiDeleteStorage()
|
try await apiDeleteStorage()
|
||||||
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
do {
|
do {
|
||||||
let config = ArchiveConfig(archivePath: archivePath.path)
|
let config = ArchiveConfig(archivePath: archivePath.path)
|
||||||
let archiveErrors = try await apiImportArchive(config: config)
|
let archiveErrors = try await apiImportArchive(config: config)
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ struct MigrateToAppGroupView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||||
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
try await apiExportArchive(config: config)
|
try await apiExportArchive(config: config)
|
||||||
await MainActor.run { setV3DBMigration(.exported) }
|
await MainActor.run { setV3DBMigration(.exported) }
|
||||||
} catch let error {
|
} catch let error {
|
||||||
@@ -231,6 +232,7 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
|
|||||||
if !ChatModel.shared.chatDbChanged {
|
if !ChatModel.shared.chatDbChanged {
|
||||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||||
}
|
}
|
||||||
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
try await apiExportArchive(config: config)
|
try await apiExportArchive(config: config)
|
||||||
if storagePath == nil {
|
if storagePath == nil {
|
||||||
deleteOldArchive()
|
deleteOldArchive()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ChatInfoImage: View {
|
struct ChatInfoImage: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var size: CGFloat
|
var size: CGFloat
|
||||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||||
@@ -24,8 +24,7 @@ struct ChatInfoImage: View {
|
|||||||
case .contactRequest: iconName = "person.crop.circle.fill"
|
case .contactRequest: iconName = "person.crop.circle.fill"
|
||||||
default: iconName = "circle.fill"
|
default: iconName = "circle.fill"
|
||||||
}
|
}
|
||||||
let notesColor = colorScheme == .light ? notesChatColorLight : notesChatColorDark
|
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
|
||||||
let iconColor = if case .local = chat.chatInfo { notesColor } else { color }
|
|
||||||
return ProfileImage(
|
return ProfileImage(
|
||||||
imageStr: chat.chatInfo.image,
|
imageStr: chat.chatInfo.image,
|
||||||
iconName: iconName,
|
iconName: iconName,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// ChatItemClipShape.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Levitating Pineapple on 04/07/2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
/// Modifier, which provides clipping mask for ``ChatItemWithMenu`` view
|
||||||
|
/// and it's previews: (drag interaction, context menu, etc.)
|
||||||
|
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
|
||||||
|
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
|
||||||
|
struct ChatItemClipped: ViewModifier {
|
||||||
|
struct ClipShape: Shape {
|
||||||
|
let maxCornerRadius: Double
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
Path(
|
||||||
|
roundedRect: rect,
|
||||||
|
cornerRadius: min((rect.height / 2), maxCornerRadius),
|
||||||
|
style: .circular
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
clipShape = ClipShape(
|
||||||
|
maxCornerRadius: 18
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ chatItem: ChatItem) {
|
||||||
|
clipShape = ClipShape(
|
||||||
|
maxCornerRadius: {
|
||||||
|
switch chatItem.content {
|
||||||
|
case
|
||||||
|
.sndMsgContent,
|
||||||
|
.rcvMsgContent,
|
||||||
|
.rcvDecryptionError,
|
||||||
|
.rcvGroupInvitation,
|
||||||
|
.sndGroupInvitation,
|
||||||
|
.sndDeleted,
|
||||||
|
.rcvDeleted,
|
||||||
|
.rcvIntegrityError,
|
||||||
|
.sndModerated,
|
||||||
|
.rcvModerated,
|
||||||
|
.rcvBlocked,
|
||||||
|
.invalidJSON: 18
|
||||||
|
default: 8
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let clipShape: ClipShape
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.contentShape(.dragPreview, clipShape)
|
||||||
|
.contentShape(.contextMenuPreview, clipShape)
|
||||||
|
.clipShape(clipShape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// ChatWallpaper.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 14.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct ChatViewBackground: ViewModifier {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var image: Image
|
||||||
|
var imageType: WallpaperType
|
||||||
|
var background: Color
|
||||||
|
var tint: Color
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.background(
|
||||||
|
Canvas { context, size in
|
||||||
|
var image = context.resolve(image)
|
||||||
|
let rect = CGRectMake(0, 0, size.width, size.height)
|
||||||
|
func repeatDraw(_ imageScale: CGFloat) {
|
||||||
|
image.shading = .color(tint)
|
||||||
|
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
|
||||||
|
for h in 0 ... Int(size.height / image.size.height / scale) {
|
||||||
|
for w in 0 ... Int(size.width / image.size.width / scale) {
|
||||||
|
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
|
||||||
|
context.draw(image, in: rect, style: FillStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.fill(Path(rect), with: .color(background))
|
||||||
|
switch imageType {
|
||||||
|
case let WallpaperType.preset(filename, scale):
|
||||||
|
repeatDraw(CGFloat((scale ?? 1) * (PresetWallpaper.from(filename)?.scale ?? 1)))
|
||||||
|
case let WallpaperType.image(_, scale, scaleType):
|
||||||
|
let scaleType = scaleType ?? WallpaperScaleType.fill
|
||||||
|
switch scaleType {
|
||||||
|
case WallpaperScaleType.repeat: repeatDraw(CGFloat(scale ?? 1))
|
||||||
|
case WallpaperScaleType.fill: fallthrough
|
||||||
|
case WallpaperScaleType.fit:
|
||||||
|
let scale = scaleType.computeScaleFactor(image.size, size)
|
||||||
|
let scaledWidth = (image.size.width * scale.0)
|
||||||
|
let scaledHeight = (image.size.height * scale.1)
|
||||||
|
context.draw(image, in: CGRectMake(((size.width - scaledWidth) / 2), ((size.height - scaledHeight) / 2), scaledWidth, scaledHeight), style: FillStyle())
|
||||||
|
if case WallpaperScaleType.fit = scaleType {
|
||||||
|
if scaledWidth < size.width {
|
||||||
|
// has black lines at left and right sides
|
||||||
|
var x = (size.width - scaledWidth) / 2
|
||||||
|
while x > 0 {
|
||||||
|
context.draw(image, in: CGRectMake((x - scaledWidth), ((size.height - scaledHeight) / 2), scaledWidth, scaledHeight), style: FillStyle())
|
||||||
|
x -= scaledWidth
|
||||||
|
}
|
||||||
|
x = size.width - (size.width - scaledWidth) / 2
|
||||||
|
while x < size.width {
|
||||||
|
context.draw(image, in: CGRectMake(x, ((size.height - scaledHeight) / 2), scaledWidth, scaledHeight), style: FillStyle())
|
||||||
|
|
||||||
|
x += scaledWidth
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// has black lines at top and bottom sides
|
||||||
|
var y = (size.height - scaledHeight) / 2
|
||||||
|
while y > 0 {
|
||||||
|
context.draw(image, in: CGRectMake(((size.width - scaledWidth) / 2), (y - scaledHeight), scaledWidth, scaledHeight), style: FillStyle())
|
||||||
|
y -= scaledHeight
|
||||||
|
}
|
||||||
|
y = size.height - (size.height - scaledHeight) / 2
|
||||||
|
while y < size.height {
|
||||||
|
context.draw(image, in: CGRectMake(((size.width - scaledWidth) / 2), y, scaledWidth, scaledHeight), style: FillStyle())
|
||||||
|
y += scaledHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.fill(Path(rect), with: .color(tint))
|
||||||
|
}
|
||||||
|
case WallpaperType.empty: ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PresetWallpaper {
|
||||||
|
public func toType(_ base: DefaultTheme, _ scale: Float? = nil) -> WallpaperType {
|
||||||
|
let scale = if let scale {
|
||||||
|
scale
|
||||||
|
} else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) {
|
||||||
|
type.scale
|
||||||
|
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename })?.wallpaper?.scale {
|
||||||
|
scale
|
||||||
|
} else {
|
||||||
|
Float(1.0)
|
||||||
|
}
|
||||||
|
return WallpaperType.preset(
|
||||||
|
filename,
|
||||||
|
scale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
//
|
|
||||||
// ContextMenu2.swift
|
|
||||||
// SimpleX (iOS)
|
|
||||||
//
|
|
||||||
// Created by Evgeny on 09/08/2022.
|
|
||||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
func uiKitContextMenu(hasImageOrVideo: Bool, maxWidth: CGFloat, itemWidth: Binding<CGFloat>, menu: Binding<UIMenu>, allowMenu: Binding<Bool>) -> some View {
|
|
||||||
Group {
|
|
||||||
if allowMenu.wrappedValue {
|
|
||||||
if hasImageOrVideo {
|
|
||||||
InteractionView(content:
|
|
||||||
self.environmentObject(ChatModel.shared)
|
|
||||||
.overlay(DetermineWidthImageVideoItem())
|
|
||||||
.onPreferenceChange(DetermineWidthImageVideoItem.Key.self) { itemWidth.wrappedValue = $0 == 0 ? maxWidth : $0 }
|
|
||||||
, maxWidth: maxWidth, itemWidth: itemWidth, menu: menu)
|
|
||||||
.frame(maxWidth: itemWidth.wrappedValue)
|
|
||||||
} else {
|
|
||||||
InteractionView(content: self.environmentObject(ChatModel.shared), maxWidth: maxWidth, itemWidth: itemWidth, menu: menu)
|
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class HostingViewHolder: UIView {
|
|
||||||
var contentSize: CGSize = CGSizeMake(0, 0)
|
|
||||||
override var intrinsicContentSize: CGSize { get { contentSize } }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct InteractionView<Content: View>: UIViewRepresentable {
|
|
||||||
let content: Content
|
|
||||||
var maxWidth: CGFloat
|
|
||||||
var itemWidth: Binding<CGFloat>
|
|
||||||
@Binding var menu: UIMenu
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
|
||||||
let view = HostingViewHolder()
|
|
||||||
view.backgroundColor = .clear
|
|
||||||
let hostView = UIHostingController(rootView: content)
|
|
||||||
view.contentSize = hostView.view.intrinsicContentSize
|
|
||||||
hostView.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
let constraints = [
|
|
||||||
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor),
|
|
||||||
hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor)
|
|
||||||
]
|
|
||||||
view.addSubview(hostView.view)
|
|
||||||
view.addConstraints(constraints)
|
|
||||||
view.layer.cornerRadius = 18
|
|
||||||
hostView.view.layer.cornerRadius = 18
|
|
||||||
let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
|
|
||||||
view.addInteraction(menuInteraction)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIView, context: Context) {
|
|
||||||
let was = (uiView as! HostingViewHolder).contentSize
|
|
||||||
(uiView as! HostingViewHolder).contentSize = uiView.subviews[0].sizeThatFits(CGSizeMake(itemWidth.wrappedValue, .infinity))
|
|
||||||
if was != (uiView as! HostingViewHolder).contentSize {
|
|
||||||
uiView.invalidateIntrinsicContentSize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
|
|
||||||
let parent: InteractionView<Content>
|
|
||||||
|
|
||||||
init(_ parent: InteractionView<Content>) {
|
|
||||||
self.parent = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
func contextMenuInteraction(
|
|
||||||
_ interaction: UIContextMenuInteraction,
|
|
||||||
configurationForMenuAtLocation location: CGPoint
|
|
||||||
) -> UIContextMenuConfiguration? {
|
|
||||||
UIContextMenuConfiguration(
|
|
||||||
identifier: nil,
|
|
||||||
previewProvider: nil,
|
|
||||||
actionProvider: { [weak self] _ in
|
|
||||||
guard let self = self else { return nil }
|
|
||||||
return self.parent.menu
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// func contextMenuInteraction(
|
|
||||||
// _ interaction: UIContextMenuInteraction,
|
|
||||||
// willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
|
|
||||||
// animator: UIContextMenuInteractionCommitAnimating
|
|
||||||
// ) {
|
|
||||||
// animator.addCompletion {
|
|
||||||
// print("user tapped")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
|||||||
typealias UIViewControllerType = PHPickerViewController
|
typealias UIViewControllerType = PHPickerViewController
|
||||||
var addMedia: (_ content: UploadContent) async -> Void
|
var addMedia: (_ content: UploadContent) async -> Void
|
||||||
var selectionLimit: Int
|
var selectionLimit: Int
|
||||||
|
var filter: PHPickerFilter = .any(of: [.images, .videos])
|
||||||
var finishedPreprocessing: () -> Void = {}
|
var finishedPreprocessing: () -> Void = {}
|
||||||
var didFinishPicking: (_ didSelectItems: Bool) async -> Void
|
var didFinishPicking: (_ didSelectItems: Bool) async -> Void
|
||||||
|
|
||||||
@@ -148,7 +149,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||||
var config = PHPickerConfiguration()
|
var config = PHPickerConfiguration()
|
||||||
config.filter = .any(of: [.images, .videos])
|
config.filter = filter
|
||||||
config.selectionLimit = selectionLimit
|
config.selectionLimit = selectionLimit
|
||||||
config.selection = .ordered
|
config.selection = .ordered
|
||||||
config.preferredAssetRepresentationMode = .current
|
config.preferredAssetRepresentationMode = .current
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import SimpleXChat
|
|||||||
let defaultProfileImageCorner = 22.5
|
let defaultProfileImageCorner = 22.5
|
||||||
|
|
||||||
struct ProfileImage: View {
|
struct ProfileImage: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var imageStr: String? = nil
|
var imageStr: String? = nil
|
||||||
var iconName: String = "person.crop.circle.fill"
|
var iconName: String = "person.crop.circle.fill"
|
||||||
var size: CGFloat
|
var size: CGFloat
|
||||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||||
|
var backgroundColor: Color? = nil
|
||||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -24,10 +26,12 @@ struct ProfileImage: View {
|
|||||||
let uiImage = UIImage(data: data) {
|
let uiImage = UIImage(data: data) {
|
||||||
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
|
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
|
||||||
} else {
|
} else {
|
||||||
|
let c = color.asAnotherColorFromSecondaryVariant(theme)
|
||||||
Image(systemName: iconName)
|
Image(systemName: iconName)
|
||||||
.resizable()
|
.resizable()
|
||||||
.foregroundColor(color)
|
.foregroundColor(c)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
|
.background(Circle().fill(backgroundColor != nil ? backgroundColor! : .clear))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +55,29 @@ private let radiusFactor = (1 - squareToCircleRatio) / 50
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func asAnotherColorFromSecondaryVariant(_ theme: AppTheme) -> Color {
|
||||||
|
let s = theme.colors.secondaryVariant
|
||||||
|
let l = theme.colors.isLight
|
||||||
|
return switch self {
|
||||||
|
case Color(uiColor: .tertiaryLabel): // ChatView title
|
||||||
|
l ? s.darker(0.05) : s.lighter(0.2)
|
||||||
|
case Color(uiColor: .tertiarySystemFill): // SettingsView, ChatInfoView
|
||||||
|
l ? s.darker(0.065) : s.lighter(0.085)
|
||||||
|
case Color(uiColor: .quaternaryLabel): // ChatListView user picker
|
||||||
|
l ? s.darker(0.1) : s.lighter(0.1)
|
||||||
|
case Color(uiColor: .tertiarySystemGroupedBackground): // ChatListView items, forward view
|
||||||
|
s.asGroupedBackground(theme.base.mode)
|
||||||
|
default: self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ProfileImage_Previews: PreviewProvider {
|
struct ProfileImage_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ProfileImage(imageStr: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC", size: 63)
|
ProfileImage(imageStr: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC", size: 63)
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
//
|
||||||
|
// ThemeModeEditor.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 20.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct UserWallpaperEditor: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var initialTheme: ThemeModeOverride
|
||||||
|
@State var themeModeOverride: ThemeModeOverride
|
||||||
|
@State var applyToMode: DefaultThemeMode?
|
||||||
|
@State var showMore: Bool = false
|
||||||
|
@Binding var globalThemeUsed: Bool
|
||||||
|
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
|
||||||
|
|
||||||
|
@State private var showImageImporter: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
let wallpaperType = theme.wallpaper.type
|
||||||
|
|
||||||
|
WallpaperPresetSelector(
|
||||||
|
selectedWallpaper: wallpaperType,
|
||||||
|
currentColors: { type in
|
||||||
|
// If applying for :
|
||||||
|
// - all themes: no overrides needed
|
||||||
|
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
|
||||||
|
let perUserOverride: ThemeModeOverrides? = wallpaperType.sameType(type) ? ChatModel.shared.currentUser?.uiThemes : nil
|
||||||
|
return ThemeManager.currentColors(type, nil, perUserOverride, themeOverridesDefault.get())
|
||||||
|
},
|
||||||
|
onChooseType: onChooseType
|
||||||
|
)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
.listRowInsets(.init())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.modifier(WallpaperImporter(showImageImporter: $showImageImporter, onChooseImage: { image in
|
||||||
|
if let filename = saveWallpaperFile(image: image) {
|
||||||
|
_ = onTypeCopyFromSameTheme(WallpaperType.image(filename, 1, WallpaperScaleType.fill))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
WallpaperSetupView(
|
||||||
|
wallpaperType: themeModeOverride.type,
|
||||||
|
base: theme.base,
|
||||||
|
initialWallpaper: theme.wallpaper,
|
||||||
|
editColor: { name in editColor(name, theme) },
|
||||||
|
onTypeChange: onTypeChange
|
||||||
|
)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if !globalThemeUsed {
|
||||||
|
ResetToGlobalThemeButton(true, theme.colors.primary) {
|
||||||
|
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
globalThemeUsed = true
|
||||||
|
Task {
|
||||||
|
await save(applyToMode, nil)
|
||||||
|
await MainActor.run {
|
||||||
|
// Change accent color globally
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetDefaultThemeButton(theme.colors.primary) {
|
||||||
|
globalThemeUsed = false
|
||||||
|
let lightBase = DefaultTheme.LIGHT
|
||||||
|
let darkBase = if theme.base != DefaultTheme.LIGHT { theme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
|
||||||
|
let mode = themeModeOverride.mode
|
||||||
|
Task {
|
||||||
|
// Saving for both modes in one place by changing mode once per save
|
||||||
|
if applyToMode == nil {
|
||||||
|
let oppositeMode = mode == DefaultThemeMode.light ? DefaultThemeMode.dark : DefaultThemeMode.light
|
||||||
|
await save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, oppositeMode == DefaultThemeMode.light ? lightBase : darkBase))
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
themeModeOverride = ThemeModeOverride.withFilledAppDefaults(mode, mode == DefaultThemeMode.light ? lightBase : darkBase)
|
||||||
|
}
|
||||||
|
await save(themeModeOverride.mode, themeModeOverride)
|
||||||
|
await MainActor.run {
|
||||||
|
// Change accent color globally
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onChange(of: initialTheme.mode) { mode in
|
||||||
|
themeModeOverride = initialTheme
|
||||||
|
if applyToMode != nil {
|
||||||
|
applyToMode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: theme) { _ in
|
||||||
|
// Applies updated global theme if current one tracks global theme
|
||||||
|
if globalThemeUsed {
|
||||||
|
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
globalThemeUsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showMore {
|
||||||
|
let values = [
|
||||||
|
(nil, "All modes"),
|
||||||
|
(DefaultThemeMode.light, "Light mode"),
|
||||||
|
(DefaultThemeMode.dark, "Dark mode")
|
||||||
|
]
|
||||||
|
Picker("Apply to", selection: $applyToMode) {
|
||||||
|
ForEach(values, id: \.0) { (_, text) in
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
.onChange(of: applyToMode) { mode in
|
||||||
|
if let mode, mode != theme.base.mode {
|
||||||
|
let lightBase = DefaultTheme.LIGHT
|
||||||
|
let darkBase = if theme.base != DefaultTheme.LIGHT { theme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
|
||||||
|
ThemeManager.applyTheme(mode == DefaultThemeMode.light ? lightBase.themeName : darkBase.themeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
|
||||||
|
|
||||||
|
ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
|
||||||
|
let importedFromString = imported.wallpaper?.importFromString()
|
||||||
|
let importedType = importedFromString?.toAppWallpaper().type
|
||||||
|
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
|
||||||
|
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||||
|
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
|
||||||
|
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||||
|
Task {
|
||||||
|
await MainActor.run {
|
||||||
|
themeModeOverride = res
|
||||||
|
}
|
||||||
|
await save(applyToMode, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
|
||||||
|
_ = ThemeManager.copyFromSameThemeOverrides(type, nil, $themeModeOverride)
|
||||||
|
Task {
|
||||||
|
await save(applyToMode, themeModeOverride)
|
||||||
|
}
|
||||||
|
globalThemeUsed = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preApplyGlobalIfNeeded(_ type: WallpaperType?) {
|
||||||
|
if globalThemeUsed {
|
||||||
|
_ = onTypeCopyFromSameTheme(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onTypeChange(_ type: WallpaperType?) {
|
||||||
|
if globalThemeUsed {
|
||||||
|
preApplyGlobalIfNeeded(type)
|
||||||
|
// Saves copied static image instead of original from global theme
|
||||||
|
ThemeManager.applyWallpaper(themeModeOverride.type, $themeModeOverride)
|
||||||
|
} else {
|
||||||
|
ThemeManager.applyWallpaper(type, $themeModeOverride)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await save(applyToMode, themeModeOverride)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentColors(_ type: WallpaperType?) -> ThemeManager.ActiveTheme {
|
||||||
|
// If applying for :
|
||||||
|
// - all themes: no overrides needed
|
||||||
|
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
|
||||||
|
let perUserOverride: ThemeModeOverrides? = theme.wallpaper.type.sameType(type) ? ChatModel.shared.currentUser?.uiThemes : nil
|
||||||
|
return ThemeManager.currentColors(type, nil, perUserOverride, themeOverridesDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onChooseType(_ type: WallpaperType?) {
|
||||||
|
if let type, case WallpaperType.image = type {
|
||||||
|
if theme.wallpaper.type.isImage || currentColors(type).wallpaper.type.image == nil {
|
||||||
|
showImageImporter = true
|
||||||
|
} else {
|
||||||
|
_ = onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)
|
||||||
|
}
|
||||||
|
} else if themeModeOverride.type != type || theme.wallpaper.type != type {
|
||||||
|
_ = onTypeCopyFromSameTheme(type)
|
||||||
|
} else {
|
||||||
|
onTypeChange(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editColor(_ name: ThemeColor, _ currentTheme: AppTheme) -> Binding<Color> {
|
||||||
|
editColorBinding(
|
||||||
|
name: name,
|
||||||
|
wallpaperType: theme.wallpaper.type,
|
||||||
|
wallpaperImage: theme.wallpaper.type.image,
|
||||||
|
theme: currentTheme,
|
||||||
|
onColorChange: { color in
|
||||||
|
preApplyGlobalIfNeeded(themeModeOverride.type)
|
||||||
|
ThemeManager.applyThemeColor(name: name, color: color, pref: $themeModeOverride)
|
||||||
|
Task { await save(applyToMode, themeModeOverride) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatWallpaperEditor: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@State private var currentTheme: ThemeManager.ActiveTheme
|
||||||
|
var initialTheme: ThemeModeOverride
|
||||||
|
@State var themeModeOverride: ThemeModeOverride
|
||||||
|
@State var applyToMode: DefaultThemeMode?
|
||||||
|
@State var showMore: Bool = false
|
||||||
|
@Binding var globalThemeUsed: Bool
|
||||||
|
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
|
||||||
|
|
||||||
|
@State private var showImageImporter: Bool = false
|
||||||
|
|
||||||
|
init(initialTheme: ThemeModeOverride, themeModeOverride: ThemeModeOverride, applyToMode: DefaultThemeMode? = nil, globalThemeUsed: Binding<Bool>, save: @escaping (DefaultThemeMode?, ThemeModeOverride?) async -> Void) {
|
||||||
|
let cur = ThemeManager.currentColors(nil, globalThemeUsed.wrappedValue ? nil : themeModeOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
self.currentTheme = cur
|
||||||
|
self.initialTheme = initialTheme
|
||||||
|
self.themeModeOverride = themeModeOverride
|
||||||
|
self.applyToMode = applyToMode
|
||||||
|
self._globalThemeUsed = globalThemeUsed
|
||||||
|
self.save = save
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
WallpaperPresetSelector(
|
||||||
|
selectedWallpaper: currentTheme.wallpaper.type,
|
||||||
|
activeBackgroundColor: currentTheme.wallpaper.background,
|
||||||
|
activeTintColor: currentTheme.wallpaper.tint,
|
||||||
|
currentColors: currentColors,
|
||||||
|
onChooseType: onChooseType
|
||||||
|
)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
.listRowInsets(.init())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.modifier(WallpaperImporter(showImageImporter: $showImageImporter, onChooseImage: { image in
|
||||||
|
if let filename = saveWallpaperFile(image: image) {
|
||||||
|
_ = onTypeCopyFromSameTheme(WallpaperType.image(filename, 1, WallpaperScaleType.fill))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
WallpaperSetupView(
|
||||||
|
wallpaperType: themeModeOverride.type,
|
||||||
|
base: currentTheme.base,
|
||||||
|
initialWallpaper: currentTheme.wallpaper,
|
||||||
|
editColor: editColor,
|
||||||
|
onTypeChange: onTypeChange
|
||||||
|
)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if !globalThemeUsed {
|
||||||
|
ResetToGlobalThemeButton(ChatModel.shared.currentUser?.uiThemes?.preferredMode(isInDarkTheme()) == nil, theme.colors.primary) {
|
||||||
|
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
globalThemeUsed = true
|
||||||
|
Task {
|
||||||
|
await save(applyToMode, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetDefaultThemeButton(theme.colors.primary) {
|
||||||
|
globalThemeUsed = false
|
||||||
|
let lightBase = DefaultTheme.LIGHT
|
||||||
|
let darkBase = if currentTheme.base != DefaultTheme.LIGHT { currentTheme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
|
||||||
|
let mode = themeModeOverride.mode
|
||||||
|
Task {
|
||||||
|
// Saving for both modes in one place by changing mode once per save
|
||||||
|
if applyToMode == nil {
|
||||||
|
let oppositeMode = mode == DefaultThemeMode.light ? DefaultThemeMode.dark : DefaultThemeMode.light
|
||||||
|
await save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, oppositeMode == DefaultThemeMode.light ? lightBase : darkBase))
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
themeModeOverride = ThemeModeOverride.withFilledAppDefaults(mode, mode == DefaultThemeMode.light ? lightBase : darkBase)
|
||||||
|
}
|
||||||
|
await save(themeModeOverride.mode, themeModeOverride)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: initialTheme) { initial in
|
||||||
|
if initial.mode != themeModeOverride.mode {
|
||||||
|
themeModeOverride = initial
|
||||||
|
currentTheme = ThemeManager.currentColors(nil, globalThemeUsed ? nil : themeModeOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
if applyToMode != nil {
|
||||||
|
applyToMode = initial.mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: currentTheme) { _ in
|
||||||
|
// Applies updated global theme if current one tracks global theme
|
||||||
|
if globalThemeUsed {
|
||||||
|
themeModeOverride = ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
globalThemeUsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: themeModeOverride) { override in
|
||||||
|
currentTheme = ThemeManager.currentColors(nil, globalThemeUsed ? nil : override, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showMore {
|
||||||
|
let values = [
|
||||||
|
(nil, "All modes"),
|
||||||
|
(DefaultThemeMode.light, "Light mode"),
|
||||||
|
(DefaultThemeMode.dark, "Dark mode")
|
||||||
|
]
|
||||||
|
Picker("Apply to", selection: $applyToMode) {
|
||||||
|
ForEach(values, id: \.0) { (_, text) in
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
.onChange(of: applyToMode) { mode in
|
||||||
|
if let mode, mode != currentTheme.base.mode {
|
||||||
|
let lightBase = DefaultTheme.LIGHT
|
||||||
|
let darkBase = if currentTheme.base != DefaultTheme.LIGHT { currentTheme.base } else if systemDarkThemeDefault.get() == DefaultTheme.DARK.themeName { DefaultTheme.DARK } else if systemDarkThemeDefault.get() == DefaultTheme.BLACK.themeName { DefaultTheme.BLACK } else { DefaultTheme.SIMPLEX }
|
||||||
|
ThemeManager.applyTheme(mode == DefaultThemeMode.light ? lightBase.themeName : darkBase.themeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomizeThemeColorsSection(editColor: editColor)
|
||||||
|
|
||||||
|
ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
|
||||||
|
let importedFromString = imported.wallpaper?.importFromString()
|
||||||
|
let importedType = importedFromString?.toAppWallpaper().type
|
||||||
|
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
|
||||||
|
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
|
||||||
|
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
|
||||||
|
Task {
|
||||||
|
await MainActor.run {
|
||||||
|
themeModeOverride = res
|
||||||
|
}
|
||||||
|
await save(applyToMode, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
|
||||||
|
let success = ThemeManager.copyFromSameThemeOverrides(type, ChatModel.shared.currentUser?.uiThemes?.preferredMode(!currentTheme.colors.isLight), $themeModeOverride)
|
||||||
|
if success {
|
||||||
|
Task {
|
||||||
|
await save(applyToMode, themeModeOverride)
|
||||||
|
}
|
||||||
|
globalThemeUsed = false
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preApplyGlobalIfNeeded(_ type: WallpaperType?) {
|
||||||
|
if globalThemeUsed {
|
||||||
|
_ = onTypeCopyFromSameTheme(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onTypeChange(_ type: WallpaperType?) {
|
||||||
|
if globalThemeUsed {
|
||||||
|
preApplyGlobalIfNeeded(type)
|
||||||
|
// Saves copied static image instead of original from global theme
|
||||||
|
ThemeManager.applyWallpaper(themeModeOverride.type, $themeModeOverride)
|
||||||
|
} else {
|
||||||
|
ThemeManager.applyWallpaper(type, $themeModeOverride)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await save(applyToMode, themeModeOverride)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentColors(_ type: WallpaperType?) -> ThemeManager.ActiveTheme {
|
||||||
|
// If applying for :
|
||||||
|
// - all themes: no overrides needed
|
||||||
|
// - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected
|
||||||
|
let perChatOverride: ThemeModeOverride? = type?.sameType(themeModeOverride.type) == true ? themeModeOverride : nil
|
||||||
|
return ThemeManager.currentColors(type, perChatOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onChooseType(_ type: WallpaperType?) {
|
||||||
|
if let type, case WallpaperType.image = type {
|
||||||
|
if (themeModeOverride.type?.isImage == true && !globalThemeUsed) || currentColors(type).wallpaper.type.image == nil {
|
||||||
|
showImageImporter = true
|
||||||
|
} else if !onTypeCopyFromSameTheme(currentColors(type).wallpaper.type) {
|
||||||
|
showImageImporter = true
|
||||||
|
}
|
||||||
|
} else if globalThemeUsed || themeModeOverride.type != type || themeModeOverride.type != type {
|
||||||
|
_ = onTypeCopyFromSameTheme(type)
|
||||||
|
} else {
|
||||||
|
onTypeChange(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editColor(_ name: ThemeColor) -> Binding<Color> {
|
||||||
|
editColorBinding(
|
||||||
|
name: name,
|
||||||
|
wallpaperType: themeModeOverride.type,
|
||||||
|
wallpaperImage: themeModeOverride.type?.image,
|
||||||
|
theme: currentTheme.toAppTheme(),
|
||||||
|
onColorChange: { color in
|
||||||
|
preApplyGlobalIfNeeded(themeModeOverride.type)
|
||||||
|
ThemeManager.applyThemeColor(name: name, color: color, pref: $themeModeOverride)
|
||||||
|
currentTheme = ThemeManager.currentColors(nil, globalThemeUsed ? nil : themeModeOverride, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
Task { await save(applyToMode, themeModeOverride) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ResetToGlobalThemeButton(_ app: Bool, _ primaryColor: Color, _ onClick: @escaping () -> Void) -> some View {
|
||||||
|
Button {
|
||||||
|
onClick()
|
||||||
|
} label: {
|
||||||
|
Text(app ? "Reset to app theme" : "Reset to user theme")
|
||||||
|
.foregroundColor(primaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func SetDefaultThemeButton(_ primaryColor: Color, _ onClick: @escaping () -> Void) -> some View {
|
||||||
|
Button {
|
||||||
|
onClick()
|
||||||
|
} label: {
|
||||||
|
Text("Set default theme")
|
||||||
|
.foregroundColor(primaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func AdvancedSettingsButton(_ primaryColor: Color, _ onClick: @escaping () -> Void) -> some View {
|
||||||
|
Button {
|
||||||
|
onClick()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
Text("Advanced settings")
|
||||||
|
}.foregroundColor(primaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// ViewModifiers.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 12.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@ViewBuilder func `if`<Content: View>(_ condition: @autoclosure () -> Bool, transform: (Self) -> Content) -> some View {
|
||||||
|
if condition() {
|
||||||
|
transform(self)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||