Compare commits

..

2 Commits

Author SHA1 Message Date
Avently 1b254f0d22 revert Kotlin 2.0 update 2024-06-03 14:04:08 +07:00
Avently ee6f9c334a multiplatform: Kotlin 2.0 & Compose 1.6.10 2024-06-03 14:01:18 +07:00
648 changed files with 20467 additions and 60000 deletions
+12 -15
View File
@@ -18,7 +18,7 @@
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates). 2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend. 3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat). 4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations). 5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
[Learn more about SimpleX Chat](#contents). [Learn more about SimpleX Chat](#contents).
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
- contributing to SimpleX Chat knowledge-base. - contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started. - developing features - please connect to us via chat so we can help you get started.
## Please support us with your donations ## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat! Huge thank you to everybody who donated to SimpleX Chat!
@@ -158,19 +158,20 @@ 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) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission). - [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad - [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg - Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- USDT: - USDT:
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
- please ask if you want to donate any other coins. - Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
Thank you, Thank you,
@@ -233,10 +234,6 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md) [Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) [Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
@@ -385,10 +382,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.
+21 -74
View File
@@ -9,18 +9,35 @@
import Foundation import Foundation
import UIKit import UIKit
import SimpleXChat import SimpleXChat
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions") logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications() application.registerForRemoteNotifications()
if #available(iOS 17.0, *) { trackKeyboard() }
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled() removePasscodesIfReinstalled()
prepareForLaunch()
return true return true
} }
@available(iOS 17.0, *)
private func trackKeyboard() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@available(iOS 17.0, *)
@objc func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
}
}
@available(iOS 17.0, *)
@objc func keyboardWillHide(_ notification: Notification) {
ChatModel.shared.keyboardHeight = 0
}
@objc func pasteboardChanged() { @objc func pasteboardChanged() {
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
} }
@@ -124,10 +141,6 @@ 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
} }
@@ -135,79 +148,13 @@ 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
SceneDelegate.windowStatic = windowScene.keyWindow window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
migrateAccentColorAndTheme() window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
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)
} }
} }
@@ -1,23 +0,0 @@
{
"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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

@@ -1,23 +0,0 @@
{
"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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

@@ -1,23 +0,0 @@
{
"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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

@@ -1,23 +0,0 @@
{
"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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

@@ -1,23 +0,0 @@
{
"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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

@@ -1,23 +0,0 @@
{
"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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+1 -24
View File
@@ -14,8 +14,6 @@ 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
@@ -53,16 +51,6 @@ 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.
@@ -150,17 +138,6 @@ 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 {
@@ -247,7 +224,7 @@ struct ContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity ) .frame(maxWidth: .infinity, maxHeight: .infinity )
.background( .background(
Rectangle() Rectangle()
.fill(theme.colors.background) .fill(.background)
) )
} }
+108 -309
View File
@@ -43,95 +43,11 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item) items.append(item)
} }
class ItemsModel: ObservableObject {
static let shared = ItemsModel()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
willSet { publisher.send() }
}
var itemAdded = false {
willSet { publisher.send() }
}
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
@Published var showLoadingProgress = false
init() {
publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
let navigationTimeout = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
willNavigate()
ChatModel.shared.chatId = chatId
}
} catch {}
}
let progressTimeout = Task {
do {
try await Task.sleep(nanoseconds: 1500_000000)
await MainActor.run { showLoadingProgress = true }
} catch {}
}
Task {
if let chat = ChatModel.shared.getChat(chatId) {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 5000_000000)
await loadChat(chat: chat)
navigationTimeout.cancel()
progressTimeout.cancel()
await MainActor.run {
self.isLoading = false
self.showLoadingProgress = false
willNavigate()
ChatModel.shared.chatId = chatId
}
}
}
}
}
class NetworkModel: ObservableObject {
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
static let shared = NetworkModel()
private init() { }
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
}
}
final class ChatModel: ObservableObject { final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage? @Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false @Published var setDeliveryReceipts = false
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
@Published var currentUser: User? { @Published var currentUser: User?
didSet {
ThemeManager.applyTheme(currentThemeDefault.get())
}
}
@Published var users: [UserInfo] = [] @Published var users: [UserInfo] = []
@Published var chatInitialized = false @Published var chatInitialized = false
@Published var chatRunning: Bool? @Published var chatRunning: Bool?
@@ -145,13 +61,14 @@ final class ChatModel: ObservableObject {
// list of chat "previews" // list of chat "previews"
@Published var chats: [Chat] = [] @Published var chats: [Chat] = []
@Published var deletedChats: Set<String> = [] @Published var deletedChats: Set<String> = []
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
// current chat // current chat
@Published var chatId: String? @Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:] var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String? @Published var chatToTop: String?
@Published var groupMembers: [GMember] = [] @Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@Published var membersLoaded = false
// items in the terminal view // items in the terminal view
@Published var showingTerminal = false @Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = [] @Published var terminalItems: [TerminalItem] = []
@@ -183,6 +100,8 @@ final class ChatModel: ObservableObject {
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState? @Published var draft: ComposeState?
@Published var draftChatId: String? @Published var draftChatId: String?
// tracks keyboard height via subscription in AppDelegate
@Published var keyboardHeight: CGFloat = 0
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings @Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true) @Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
@@ -192,8 +111,6 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel() static let shared = ChatModel()
let im = ItemsModel.shared
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true let ntfEnableLocal = true
@@ -259,48 +176,19 @@ 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? {
if let i = groupMembersIndexes[groupMemberId] { groupMembers.first { $0.groupMemberId == groupMemberId }
return groupMembers[i]
}
return nil
}
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatId == groupInfo.id {
self.groupMembers = groupMembers.map { GMember.init($0) }
self.populateGroupMembersIndexes()
self.membersLoaded = true
updateView()
}
}
} }
private func getChatIndex(_ id: String) -> Int? { private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id }) chats.firstIndex(where: { $0.id == id })
} }
func addChat(_ chat: Chat) { func addChat(_ chat: Chat, at position: Int = 0) {
if chatId == nil { withAnimation {
withAnimation { addChat_(chat, at: 0) }
} else {
addChat_(chat, at: 0)
}
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
}
func addChat_(_ chat: Chat, at position: Int = 0) {
chats.insert(chat, at: position) chats.insert(chat, at: position)
} }
}
func updateChatInfo(_ cInfo: ChatInfo) { func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
@@ -373,11 +261,10 @@ final class ChatModel: ObservableObject {
} }
} }
} else { } else {
addChat_(Chat(c), at: i) addChat(Chat(c), at: i)
} }
} }
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
popChatCollector.clear()
} }
// func addGroup(_ group: SimpleXChat.Group) { // func addGroup(_ group: SimpleXChat.Group) {
@@ -385,12 +272,6 @@ final class ChatModel: ObservableObject {
// } // }
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// mark chat non deleted
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
updateContact(updatedContact)
}
// update previews // update previews
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = switch cInfo { chats[i].chatItems = switch cInfo {
@@ -408,9 +289,18 @@ final class ChatModel: ObservableObject {
[cItem] [cItem]
} }
if case .rcvNew = cItem.meta.itemStatus { if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1) chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!)
}
if i > 0 {
if chatId == nil {
withAnimation { popChat_(i) }
} else if chatId == cInfo.id {
chatToTop = cInfo.id
} else {
popChat_(i)
}
} }
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else { } else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
} }
@@ -425,7 +315,7 @@ final class ChatModel: ObservableObject {
var res: Bool var res: Bool
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last { if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem] chat.chatItems = [cItem]
} }
} else { } else {
@@ -436,27 +326,23 @@ final class ChatModel: ObservableObject {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true res = true
} }
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
// update current chat // update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
} }
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
withConditionalAnimation { withAnimation {
_updateChatItem(at: i, with: cItem) _updateChatItem(at: i, with: cItem)
} }
return false return false
} else { } else {
withConditionalAnimation(itemAnimation()) { withAnimation(itemAnimation()) {
var ci = cItem var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status ci.meta.itemStatus = status
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
} }
return true return true
} }
@@ -471,7 +357,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) {
withConditionalAnimation { withAnimation {
_updateChatItem(at: i, with: cItem) _updateChatItem(at: i, with: cItem)
} }
} else if let status = status { } else if let status = status {
@@ -480,17 +366,17 @@ final class ChatModel: ObservableObject {
} }
private func _updateChatItem(at i: Int, with cItem: ChatItem) { private func _updateChatItem(at i: Int, with cItem: ChatItem) {
im.reversedChatItems[i] = cItem reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now reversedChatItems[i].viewTimestamp = .now
} }
func getChatItemIndex(_ cItem: ChatItem) -> Int? { func getChatItemIndex(_ cItem: ChatItem) -> Int? {
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) reversedChatItems.firstIndex(where: { $0.id == cItem.id })
} }
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew { if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1) decreaseUnreadCounter(cInfo)
} }
// update previews // update previews
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
@@ -502,24 +388,23 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id { if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
_ = withAnimation { _ = withAnimation {
im.reversedChatItems.remove(at: i) self.reversedChatItems.remove(at: i)
} }
} }
} }
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
} }
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous { if previous {
while i < im.reversedChatItems.count - 1 { while i < reversedChatItems.count - 1 {
i += 1 i += 1
if let res = map(im.reversedChatItems[i]) { return res } if let res = map(reversedChatItems[i]) { return res }
} }
} else { } else {
while i > 0 { while i > 0 {
i -= 1 i -= 1
if let res = map(im.reversedChatItems[i]) { return res } if let res = map(reversedChatItems[i]) { return res }
} }
} }
return nil return nil
@@ -537,21 +422,10 @@ 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 {
im.reversedChatItems.insert(cItem, at: 0) reversedChatItems.insert(cItem, at: 0)
im.itemAdded = true
} }
return cItem return cItem
} }
@@ -559,15 +433,15 @@ final class ChatModel: ObservableObject {
func removeLiveDummy(animated: Bool = true) { func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy { if hasLiveDummy {
if animated { if animated {
withAnimation { _ = im.reversedChatItems.removeFirst() } withAnimation { _ = reversedChatItems.removeFirst() }
} else { } else {
_ = im.reversedChatItems.removeFirst() _ = reversedChatItems.removeFirst()
} }
} }
} }
private var hasLiveDummy: Bool { private var hasLiveDummy: Bool {
im.reversedChatItems.first?.isLiveDummy == true reversedChatItems.first?.isLiveDummy == true
} }
func markChatItemsRead(_ cInfo: ChatInfo) { func markChatItemsRead(_ cInfo: ChatInfo) {
@@ -584,7 +458,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) { private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i var j = i
while j < im.reversedChatItems.count { while j < reversedChatItems.count {
markChatItemRead_(j) markChatItemRead_(j)
j += 1 j += 1
} }
@@ -598,7 +472,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0 var unreadBelow = 0
var j = i - 1 var j = i - 1
while j >= 0 { while j >= 0 {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus { if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1 unreadBelow += 1
} }
j -= 1 j -= 1
@@ -633,146 +507,51 @@ final class ChatModel: ObservableObject {
// clear current chat // clear current chat
if chatId == cInfo.id { if chatId == cInfo.id {
chatItemStatuses = [:] chatItemStatuses = [:]
im.reversedChatItems = [] reversedChatItems = []
} }
} }
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if chatId == cInfo.id,
let itemIndex = getChatItemIndex(cItem),
im.reversedChatItems[itemIndex].isRcvNew {
await MainActor.run {
withTransaction(Transaction()) {
// update current chat
markChatItemRead_(itemIndex)
// update preview // update preview
unreadCollector.changeUnreadCounter(cInfo.id, by: -1) decreaseUnreadCounter(cInfo)
} // update current chat
} if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
} markChatItemRead_(i)
}
private let unreadCollector = UnreadCollector()
class UnreadCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var unreadCounts: [ChatId: Int] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
let m = ChatModel.shared
for (chatId, count) in self.unreadCounts {
if let i = m.getChatIndex(chatId) {
m.changeUnreadCounter(i, by: count)
}
}
self.unreadCounts = [:]
}
.store(in: &bag)
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
DispatchQueue.main.async {
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
}
subject.send()
}
}
let popChatCollector = PopChatCollector()
class PopChatCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var chatsToPop: [ChatId: Date] = [:]
private let popTsComparator = KeyPathComparator<Chat>(\.popTs, order: .reverse)
init() {
subject
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
.sink { self.popCollectedChats() }
.store(in: &bag)
}
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
let m = ChatModel.shared
if currentPosition > 0 && m.chatId == chatId {
m.chatToTop = chatId
}
if currentPosition > 0 || !chatsToPop.isEmpty {
chatsToPop[chatId] = Date.now
subject.send()
}
}
func clear() {
chatsToPop = [:]
}
func popCollectedChats() {
let m = ChatModel.shared
var ixs: IndexSet = []
var chs: [Chat] = []
// collect chats that received updates
for (chatId, popTs) in self.chatsToPop {
// Currently opened chat is excluded, removing it from the list would navigate out of it
// It will be popped to top later when user exits from the list.
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
ixs.insert(i)
let ch = m.chats[i]
ch.popTs = popTs
chs.append(ch)
}
}
let removeInsert = {
m.chats.remove(atOffsets: ixs)
// sort chats by pop timestamp in descending order
m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
}
if m.chatId == nil {
withAnimation { removeInsert() }
} else {
removeInsert()
}
self.chatsToPop = [:]
} }
} }
private func markChatItemRead_(_ i: Int) { private func markChatItemRead_(_ i: Int) {
let meta = im.reversedChatItems[i].meta let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus { if case .rcvNew = meta.itemStatus {
im.reversedChatItems[i].meta.itemStatus = .rcvRead reversedChatItems[i].meta.itemStatus = .rcvRead
im.reversedChatItems[i].viewTimestamp = .now reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl { if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
} }
} }
} }
func changeUnreadCounter(_ chatIndex: Int, by count: Int) { func decreaseUnreadCounter(_ cInfo: ChatInfo) {
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count if let i = getChatIndex(cInfo.id) {
changeUnreadCounter(user: currentUser!, by: count) chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
decreaseUnreadCounter(user: currentUser!)
}
} }
func increaseUnreadCounter(user: any UserLike) { func increaseUnreadCounter(user: any UserLike) {
changeUnreadCounter(user: user, by: 1) changeUnreadCounter(user: user, by: 1)
NtfManager.shared.incNtfBadgeCount()
} }
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by) changeUnreadCounter(user: user, by: -by)
NtfManager.shared.decNtfBadgeCount(by: by)
} }
private func changeUnreadCounter(user: any UserLike, by: Int) { private func changeUnreadCounter(user: any UserLike, by: Int) {
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) { if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
users[i].unreadCount += by users[i].unreadCount += by
} }
NtfManager.shared.changeNtfBadgeCount(by: by)
} }
func totalUnreadCountForAllUsers() -> Int { func totalUnreadCountForAllUsers() -> Int {
@@ -786,8 +565,8 @@ final class ChatModel: ObservableObject {
var ns: [String] = [] var ns: [String] = []
if let ciCategory = chatItem.mergeCategory, if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) { var i = getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count { while i < reversedChatItems.count {
let ci = im.reversedChatItems[i] let ci = reversedChatItems[i]
if ci.mergeCategory != ciCategory { break } if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected { if let m = ci.memberConnected {
ns.append(m.displayName) ns.append(m.displayName)
@@ -802,7 +581,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index) // returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) { if let i = getChatItemIndex(ci) {
(i, i > 0 ? im.reversedChatItems[i - 1] : nil) (i, i > 0 ? reversedChatItems[i - 1] : nil)
} else { } else {
(nil, nil) (nil, nil)
} }
@@ -812,10 +591,10 @@ final class ChatModel: ObservableObject {
// and the previous visible item with another merge category // and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) { func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) } guard var i = ciIndex else { return (nil, nil) }
let fst = im.reversedChatItems.count - 1 let fst = reversedChatItems.count - 1
while i < fst { while i < fst {
i = i + 1 i = i + 1
let ci = im.reversedChatItems[i] let ci = reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory { if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci) return (i - 1, ci)
} }
@@ -828,7 +607,7 @@ final class ChatModel: ObservableObject {
var prevMember: GroupMember? = nil var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = [] var memberIds: Set<Int64> = []
for i in range { for i in range {
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir { if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId) memberIds.insert(m.groupMemberId)
} }
@@ -838,7 +617,6 @@ final class ChatModel: ObservableObject {
func popChat(_ id: String) { func popChat(_ id: String) {
if let i = getChatIndex(id) { if let i = getChatIndex(id) {
// no animation here, for it not to look like it just moved when leaving the chat
popChat_(i) popChat_(i)
} }
} }
@@ -873,17 +651,14 @@ final class ChatModel: ObservableObject {
} }
// update current chat // update current chat
if chatId == groupInfo.id { if chatId == groupInfo.id {
if let i = groupMembersIndexes[member.groupMemberId] { if let i = groupMembers.firstIndex(where: { $0.groupMemberId == 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 { withAnimation { groupMembers.append(GMember(member)) }
groupMembers.append(GMember(member))
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
}
return true return true
} }
} else { } else {
@@ -904,29 +679,37 @@ final class ChatModel: ObservableObject {
var i = 0 var i = 0
var totalBelow = 0 var totalBelow = 0
var unreadBelow = 0 var unreadBelow = 0
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) { while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1 totalBelow += 1
if im.reversedChatItems[i].isRcvNew { if reversedChatItems[i].isRcvNew {
unreadBelow += 1 unreadBelow += 1
} }
i += 1 i += 1
} }
return UnreadChatItemCounts( return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
// TODO these thresholds account for the fact that items are still "visible" while
// covered by compose area, they should be replaced with the actual height in pixels below the screen.
isNearBottom: totalBelow < 15,
isReallyNearBottom: totalBelow < 2,
unreadBelow: unreadBelow
)
} }
func topItemInView(itemsInView: Set<String>) -> ChatItem? { func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = im.reversedChatItems.count - 1 let maxIx = reversedChatItems.count - 1
var i = 0 var i = 0
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) } let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 } while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 } while i < maxIx && inView(i) { i += 1 }
return im.reversedChatItems[min(i - 1, maxIx)] return reversedChatItems[min(i - 1, maxIx)]
}
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
} }
} }
@@ -940,18 +723,16 @@ struct NTFContactRequest {
var chatId: String var chatId: String
} }
struct UnreadChatItemCounts: Equatable { struct UnreadChatItemCounts {
var isNearBottom: Bool var totalBelow: Int
var isReallyNearBottom: Bool
var unreadBelow: Int var unreadBelow: Int
} }
final class Chat: ObservableObject, Identifiable, ChatLike { final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo @Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem] @Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats @Published var chatStats: ChatStats
var created = Date.now var created = Date.now
fileprivate var popTs: Date?
init(_ cData: ChatData) { init(_ cData: ChatData) {
self.chatInfo = cData.chatInfo self.chatInfo = cData.chatInfo
@@ -998,6 +779,24 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
if case let .group(groupInfo) = self.chatInfo {
let p = groupInfo.fullGroupPreferences
return switch feature {
case .timedMessages: p.timedMessages.on
case .directMessages: p.directMessages.on(for: groupInfo.membership)
case .fullDelete: p.fullDelete.on
case .reactions: p.reactions.on
case .voice: p.voice.on(for: groupInfo.membership)
case .files: p.files.on(for: groupInfo.membership)
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
case .history: p.history.on
}
} else {
return true
}
}
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
} }
@@ -7,19 +7,18 @@
// //
import Foundation import Foundation
import SimpleXChat
import SwiftUI import SwiftUI
import AVKit import AVKit
import SwiftyGif
import LinkPresentation
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? { func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
if let file = file, file.loaded { if let file = file, file.loaded {
return file.fileSource return file.fileSource
} }
return nil return nil
} }
public func getLoadedImage(_ file: CIFile?) -> UIImage? { func getLoadedImage(_ file: CIFile?) -> UIImage? {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath) let filePath = getAppFilePath(fileSource.filePath)
do { do {
@@ -38,7 +37,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil return nil
} }
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data { func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
if let cfArgs = cfArgs { if let cfArgs = cfArgs {
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs) return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
} else { } else {
@@ -46,7 +45,7 @@ public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
} }
} }
public func getLoadedVideo(_ file: CIFile?) -> URL? { func getLoadedVideo(_ file: CIFile?) -> URL? {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath) let filePath = getAppFilePath(fileSource.filePath)
if FileManager.default.fileExists(atPath: filePath.path) { if FileManager.default.fileExists(atPath: filePath.path) {
@@ -56,13 +55,13 @@ public func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil return nil
} }
public func saveAnimImage(_ image: UIImage) -> CryptoFile? { func saveAnimImage(_ image: UIImage) -> CryptoFile? {
let fileName = generateNewFileName("IMG", "gif") let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil } guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
} }
public func saveImage(_ uiImage: UIImage) -> CryptoFile? { func saveImage(_ uiImage: UIImage) -> CryptoFile? {
let hasAlpha = imageHasAlpha(uiImage) let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg" let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) { if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
@@ -72,7 +71,7 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
return nil return nil
} }
public func cropToSquare(_ image: UIImage) -> UIImage { func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size let size = image.size
let side = min(size.width, size.height) let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side) let newSize = CGSize(width: side, height: side)
@@ -85,7 +84,7 @@ public func cropToSquare(_ image: UIImage) -> UIImage {
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image)) return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
} }
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? { func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
var img = image var img = image
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0 var dataSize = data?.count ?? 0
@@ -100,7 +99,7 @@ public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha
return data return data
} }
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image var img = image
let hasAlpha = imageHasAlpha(image) let hasAlpha = imageHasAlpha(image)
var str = compressImageStr(img, hasAlpha: hasAlpha) var str = compressImageStr(img, hasAlpha: hasAlpha)
@@ -116,7 +115,7 @@ public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String
return str return str
} }
public func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? { func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
let ext = hasAlpha ? "png" : "jpg" let ext = hasAlpha ? "png" : "jpg"
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())" return "data:image/\(ext);base64,\(data.base64EncodedString())"
@@ -139,7 +138,7 @@ private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, ha
} }
} }
public func imageHasAlpha(_ img: UIImage) -> Bool { func imageHasAlpha(_ img: UIImage) -> Bool {
if let cgImage = img.cgImage { if let cgImage = img.cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB() let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -159,35 +158,7 @@ public func imageHasAlpha(_ img: UIImage) -> Bool {
return false return false
} }
/// Reduces image size, while consuming less RAM func saveFileFromURL(_ url: URL) -> CryptoFile? {
///
/// Used by ShareExtension to downsize large images
/// before passing them to regular image processing pipeline
/// to avoid exceeding 120MB memory
///
/// - Parameters:
/// - url: Location of the image data
/// - size: Maximum dimension (width or height)
/// - Returns: Downsampled image or `nil`, if the image can't be located
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
autoreleasepool {
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
CGImageSourceCreateThumbnailAtIndex(
source,
0,
[
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
] as CFDictionary
)
.map { UIImage(cgImage: $0) }
} else { nil }
}
}
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
let encrypted = privacyEncryptLocalFilesGroupDefault.get() let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let savedFile: CryptoFile? let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() { if url.startAccessingSecurityScopedResource() {
@@ -213,7 +184,7 @@ public func saveFileFromURL(_ url: URL) -> CryptoFile? {
return savedFile return savedFile
} }
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? { func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do { do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get() let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent) let fileName = uniqueCombine(url.lastPathComponent)
@@ -226,6 +197,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName)) try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
savedFile = CryptoFile.plain(fileName) savedFile = CryptoFile.plain(fileName)
} }
ChatModel.shared.filesToDelete.remove(url)
return savedFile return savedFile
} catch { } catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
@@ -233,44 +205,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
} }
} }
public func saveWallpaperFile(url: URL) -> String? { func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> 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
}
}
public 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
}
}
public 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)
}
}
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath) uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
} }
@@ -302,7 +237,7 @@ private func getTimestamp() -> String {
return df.string(from: Date()) return df.string(from: Date())
} }
public func dropImagePrefix(_ s: String) -> String { func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
} }
@@ -310,23 +245,8 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
} }
public func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
extension AVAsset { extension AVAsset {
public func generatePreview() -> (UIImage, Int)? { func generatePreview() -> (UIImage, Int)? {
let generator = AVAssetImageGenerator(asset: self) let generator = AVAssetImageGenerator(asset: self)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
var actualTime = CMTimeMake(value: 0, timescale: 0) var actualTime = CMTimeMake(value: 0, timescale: 0)
@@ -338,7 +258,7 @@ extension AVAsset {
} }
extension UIImage { extension UIImage {
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage { func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
if let cgImage = cgImage { if let cgImage = cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB() let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -383,75 +303,4 @@ extension UIImage {
} }
return self return self
} }
public convenience init?(base64Encoded: String?) {
if let base64Encoded, let data = Data(base64Encoded: dropImagePrefix(base64Encoded)) {
self.init(data: data)
} else {
return nil
}
}
}
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
logger.error("Could not load link preview image")
cb(nil)
}
}
}
public func getLinkPreview(for url: URL) async -> LinkPreview? {
await withCheckedContinuation { cont in
getLinkPreview(url: url) { cont.resume(returning: $0) }
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double, blurred: Bool = false) -> some View {
if radius >= 50 {
blurredFrame(img, size, blurred).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
blurredFrame(img, sz, blurred).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
blurredFrame(img, sz, blurred)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
@ViewBuilder private func blurredFrame(_ img: Image, _ size: CGFloat, _ blurred: Bool) -> some View {
let v = img.resizable().frame(width: size, height: size)
if blurred {
v.blur(radius: size / 4)
} else {
v
}
} }
+7 -5
View File
@@ -57,9 +57,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction) chatModel.ntfCallInvitationAction = (chatId, ntfAction)
} }
} else { } else {
if let chatId = content.targetContentIdentifier { chatModel.chatId = content.targetContentIdentifier
ItemsModel.shared.loadOpenChat(chatId)
}
} }
handler() handler()
} }
@@ -240,8 +238,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ntfBadgeCountGroupDefault.set(count) ntfBadgeCountGroupDefault.set(count)
} }
func changeNtfBadgeCount(by count: Int = 1) { func decNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count)) setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
}
func incNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
} }
private func addNotification(_ content: UNMutableNotificationContent) { private func addNotification(_ content: UNMutableNotificationContent) {
+76 -229
View File
@@ -92,15 +92,12 @@ 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, log: Bool = true) -> ChatResponse { func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
if log {
logger.debug("chatSendCmd \(cmd.cmdType)") 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)
if log {
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp { if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
@@ -108,7 +105,6 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
Task { Task {
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp) await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
} }
}
return resp return resp
} }
@@ -137,8 +133,8 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
} }
} }
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl) let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
if case let .activeUser(user) = r { return user } if case let .activeUser(user) = r { return user }
throw r throw r
} }
@@ -217,7 +213,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
} }
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool { func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl) let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
switch r { switch r {
case .chatStarted: return true case .chatStarted: return true
case .chatRunning: return false case .chatRunning: return false
@@ -225,15 +221,6 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
} }
} }
func apiCheckChatRunning() throws -> Bool {
let r = chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
default: throw r
}
}
func apiStopChat() async throws { func apiStopChat() async throws {
let r = await chatSendCmd(.apiStopChat) let r = await chatSendCmd(.apiStopChat)
switch r { switch r {
@@ -255,8 +242,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
logger.error("apiSuspendChat error: \(String(describing: r))") logger.error("apiSuspendChat error: \(String(describing: r))")
} }
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl) let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
if case .cmdOk = r { return }
throw r
}
func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
if case .cmdOk = r { return } if case .cmdOk = r { return }
throw r throw r
} }
@@ -279,10 +272,8 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
throw r throw r
} }
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { func apiExportArchive(config: ArchiveConfig) async throws {
let r = await chatSendCmd(.apiExportArchive(config: config)) try await sendCommandOkResp(.apiExportArchive(config: config))
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
throw r
} }
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
@@ -318,10 +309,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
throw r throw r
} }
let loadItemsPerPage = 50 func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) } if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r throw r
} }
@@ -332,20 +321,15 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
throw r throw r
} }
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { func loadChat(chat: Chat, search: String = "") {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
let m = ChatModel.shared let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:] m.chatItemStatuses = [:]
if clearItems { m.reversedChatItems = []
await MainActor.run { im.reversedChatItems = [] } let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
}
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo) m.updateChatInfo(chat.chatInfo)
} m.reversedChatItems = chat.chatItems.reversed()
} catch let error { } catch let error {
logger.error("loadChat error: \(responseError(error))") logger.error("loadChat error: \(responseError(error))")
} }
@@ -413,7 +397,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))") logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Error sending message", title: "Error sending message",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
@@ -421,7 +405,7 @@ private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItem error: \(String(describing: r))") logger.error("apiCreateChatItem error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Error creating message", title: "Error creating message",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
@@ -437,15 +421,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
throw r throw r
} }
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay) let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items } if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r throw r
} }
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] { func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay) let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items } if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r throw r
} }
@@ -557,11 +541,6 @@ 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))
} }
@@ -705,7 +684,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
return ((.contact, connection), nil) return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact): case let .contactAlreadyExists(_, contact):
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id) await MainActor.run { m.chatId = c.id }
} }
let alert = contactAlreadyExistsAlert(contact) let alert = contactAlreadyExistsAlert(contact)
return (nil, alert) return (nil, alert)
@@ -715,7 +694,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Please check that you used the correct link or ask your contact to send you another one." message: "Please check that you used the correct link or ask your contact to send you another one."
) )
return (nil, alert) return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
let alert = mkAlert( let alert = mkAlert(
title: "Connection error (AUTH)", title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
@@ -748,7 +727,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
} else { } else {
return mkAlert( return mkAlert(
title: "Connection error", title: "Connection error",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
} }
@@ -765,38 +744,22 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
return (nil, alert) return (nil, alert)
} }
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws { func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
let chatId = type.rawValue + id.description let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) } DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
if case .direct = type, case .contactDeleted = r { return } if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return } if case .group = type, case .groupDeletedUser = r { return }
throw r throw r
} }
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact { func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
let type: ChatType = .direct
let chatId = type.rawValue + id.description
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
}
defer {
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
throw r
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode) try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) } DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error { } catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))") logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -806,39 +769,6 @@ func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: tru
} }
} }
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
do {
let cInfo = chat.chatInfo
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run {
switch chatDeleteMode {
case .full:
ChatModel.shared.removeChat(cInfo.id)
case .entity:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: chat.chatItems
))
case .messages:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
return mkAlert(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
return nil
}
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
@@ -901,21 +831,6 @@ 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))
@@ -963,7 +878,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
let am = AlertManager.shared let am = AlertManager.shared
if case let .acceptingContactRequest(_, contact) = r { return contact } if case let .acceptingContactRequest(_, contact) = r { return contact }
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r { if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
am.showAlertMsg( am.showAlertMsg(
title: "Connection error (AUTH)", title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request." message: "Sender may have deleted the connection request."
@@ -974,7 +889,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
logger.error("apiAcceptContactRequest error: \(String(describing: r))") logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg( am.showAlertMsg(
title: "Error accepting contact request", title: "Error accepting contact request",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
return nil return nil
@@ -1000,7 +915,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
return (fileTransferMeta, nil) return (fileTransferMeta, nil)
} else { } else {
logger.error("uploadStandaloneFile error: \(String(describing: r))") logger.error("uploadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r)) return (nil, String(describing: r))
} }
} }
@@ -1010,7 +925,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
return (rcvFileTransfer, nil) return (rcvFileTransfer, nil)
} else { } else {
logger.error("downloadStandaloneFile error: \(String(describing: r))") logger.error("downloadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r)) return (nil, String(describing: r))
} }
} }
@@ -1090,7 +1005,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
if !auto { if !auto {
am.showAlertMsg( am.showAlertMsg(
title: "Error receiving file", title: "Error receiving file",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
} }
@@ -1157,9 +1072,18 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
} }
func networkErrorAlert(_ r: ChatResponse) -> Alert? { func networkErrorAlert(_ r: ChatResponse) -> Alert? {
if let alert = getNetworkErrorAlert(r) { switch r {
return mkAlert(title: alert.title, message: alert.message) case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
} else { return mkAlert(
title: "Connection timeout",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return mkAlert(
title: "Connection error",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
default:
return nil return nil
} }
} }
@@ -1167,17 +1091,7 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async { func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) { if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
await MainActor.run { DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
ChatModel.shared.replaceChat(contactRequest.id, chat)
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
}
if contact.sndReady {
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(chat.id)
}
}
}
} }
} }
@@ -1273,7 +1187,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do { do {
logger.debug("apiMarkChatItemRead: \(cItem.id)") logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
await ChatModel.shared.markChatItemRead(cInfo, cItem) await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch { } catch {
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))") logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
} }
@@ -1314,7 +1228,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
switch r { switch r {
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo) case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
default: throw r default: throw r
} }
@@ -1420,26 +1334,6 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r throw r
} }
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId))
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
logger.error("getAgentServersSummary error: \(String(describing: r))")
throw r
}
func resetAgentServersStats() async throws {
try await sendCommandOkResp(.resetAgentServersStats)
}
private func currentUserId(_ funcName: String) throws -> Int64 { private func currentUserId(_ funcName: String) throws -> Int64 {
if let userId = ChatModel.shared.currentUser?.userId { if let userId = ChatModel.shared.currentUser?.userId {
return userId return userId
@@ -1459,7 +1353,8 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
if encryptionStartedDefault.get() { if encryptionStartedDefault.get() {
encryptionStartedDefault.set(false) encryptionStartedDefault.set(false)
} }
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path) try apiSetTempFolder(tempFolder: getTempFilesDirectory().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()
@@ -1511,16 +1406,15 @@ func startChat(refreshInvitations: Bool = true) throws {
logger.debug("startChat") logger.debug("startChat")
let m = ChatModel.shared let m = ChatModel.shared
try setNetworkConfig(getNetCfg()) try setNetworkConfig(getNetCfg())
let chatRunning = try apiCheckChatRunning() let justStarted = try apiStartChat()
m.users = try listUsers() m.users = try listUsers()
if !chatRunning { if justStarted {
try getUserChatData() try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) { if (refreshInvitations) {
try refreshCallInvitations() try refreshCallInvitations()
} }
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken() (m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called, // deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat // when it is called before startChat
if let token = m.deviceToken { if let token = m.deviceToken {
@@ -1545,7 +1439,8 @@ 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 apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl) try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
_ = try apiStartChat(ctrl: ctrl) _ = try apiStartChat(ctrl: ctrl)
return migrationActiveUser return migrationActiveUser
} }
@@ -1592,7 +1487,6 @@ func getUserChatData() throws {
m.chatItemTTL = try getChatItemTTL() m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats() let chats = try apiGetChats()
m.chats = chats.map { Chat.init($0) } m.chats = chats.map { Chat.init($0) }
m.popChatCollector.clear()
} }
private func getUserChatDataAsync() async throws { private func getUserChatDataAsync() async throws {
@@ -1605,13 +1499,11 @@ private func getUserChatDataAsync() async throws {
m.userAddress = userAddress m.userAddress = userAddress
m.chatItemTTL = chatItemTTL m.chatItemTTL = chatItemTTL
m.chats = chats.map { Chat.init($0) } m.chats = chats.map { Chat.init($0) }
m.popChatCollector.clear()
} }
} else { } else {
await MainActor.run { await MainActor.run {
m.userAddress = nil m.userAddress = nil
m.chats = [] m.chats = []
m.popChatCollector.clear()
} }
} }
} }
@@ -1661,7 +1553,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await TerminalItems.shared.add(.resp(.now, res)) await TerminalItems.shared.add(.resp(.now, res))
} }
let m = ChatModel.shared let m = ChatModel.shared
let n = NetworkModel.shared
logger.debug("processReceivedMsg: \(res.responseType)") logger.debug("processReceivedMsg: \(res.responseType)")
switch res { switch res {
case let .contactDeletedByContact(user, contact): case let .contactDeletedByContact(user, contact):
@@ -1684,7 +1575,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(user, contact) NtfManager.shared.notifyContactConnected(user, contact)
} }
await MainActor.run { await MainActor.run {
n.setContactNetworkStatus(contact, .connected) m.setContactNetworkStatus(contact, .connected)
} }
case let .contactConnecting(user, contact): case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
@@ -1696,19 +1587,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
} }
} }
case let .contactSndReady(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
}
await MainActor.run {
n.setContactNetworkStatus(contact, .connected)
}
case let .receivedContactRequest(user, contactRequest): case let .receivedContactRequest(user, contactRequest):
if active(user) { if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
@@ -1741,7 +1619,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if active(user) && m.hasChat(mergedContact.id) { if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run { await MainActor.run {
if m.chatId == mergedContact.id { if m.chatId == mergedContact.id {
ItemsModel.shared.loadOpenChat(mergedContact.id) m.chatId = intoContact.id
} }
m.removeChat(mergedContact.id) m.removeChat(mergedContact.id)
} }
@@ -1749,27 +1627,27 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .networkStatus(status, connections): case let .networkStatus(status, connections):
// dispatch queue to synchronize access // dispatch queue to synchronize access
networkStatusesLock.sync { networkStatusesLock.sync {
var ns = n.networkStatuses var ns = m.networkStatuses
// slow loop is on the background thread // slow loop is on the background thread
for cId in connections { for cId in connections {
ns[cId] = status ns[cId] = status
} }
// fast model update is on the main thread // fast model update is on the main thread
DispatchQueue.main.sync { DispatchQueue.main.sync {
n.networkStatuses = ns m.networkStatuses = ns
} }
} }
case let .networkStatuses(_, statuses): () case let .networkStatuses(_, statuses): ()
// dispatch queue to synchronize access // dispatch queue to synchronize access
networkStatusesLock.sync { networkStatusesLock.sync {
var ns = n.networkStatuses var ns = m.networkStatuses
// slow loop is on the background thread // slow loop is on the background thread
for s in statuses { for s in statuses {
ns[s.agentConnId] = s.networkStatus ns[s.agentConnId] = s.networkStatus
} }
// fast model update is on the main thread // fast model update is on the main thread
DispatchQueue.main.sync { DispatchQueue.main.sync {
n.networkStatuses = ns m.networkStatuses = ns
} }
} }
case let .newChatItem(user, aChatItem): case let .newChatItem(user, aChatItem):
@@ -1812,25 +1690,21 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem) m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
} }
} }
case let .chatItemsDeleted(user, items, _): case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
if !active(user) { if !active(user) {
for item in items { if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run { await MainActor.run {
m.decreaseUnreadCounter(user: user) m.decreaseUnreadCounter(user: user)
} }
} }
}
return return
} }
await MainActor.run { await MainActor.run {
for item in items { if let toChatItem = toChatItem {
if let toChatItem = item.toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem) _ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else { } else {
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem) m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
}
} }
} }
case let .receivedGroupInvitation(user, groupInfo, _, _): case let .receivedGroupInvitation(user, groupInfo, _, _):
@@ -1910,7 +1784,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
if let contact = memberContact { if let contact = memberContact {
await MainActor.run { await MainActor.run {
n.setContactNetworkStatus(contact, .connected) m.setContactNetworkStatus(contact, .connected)
} }
} }
case let .groupUpdated(user, toGroup): case let .groupUpdated(user, toGroup):
@@ -1952,15 +1826,11 @@ 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, _):
@@ -1982,10 +1852,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
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
@@ -2083,30 +1949,12 @@ 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 let .remoteCtrlStopped(_, rcStopReason): case .remoteCtrlStopped:
// This delay is needed to cancel the session that fails on network failure, // This delay is needed to cancel the session that fails on network failure,
// e.g. when user did not grant permission to access local network yet. // e.g. when user did not grant permission to access local network yet.
if let sess = m.remoteCtrlSession { if let sess = m.remoteCtrlSession {
await MainActor.run { await MainActor.run {
m.remoteCtrlSession = nil m.remoteCtrlSession = nil
dismissAllSheets() {
switch rcStopReason {
case .disconnected:
()
case .connectionFailed(.errorAgent(.RCP(.identity))):
AlertManager.shared.showAlertMsg(
title: "Connection with desktop stopped",
message: "This link was used with another mobile device, please create a new link on the desktop."
)
default:
AlertManager.shared.showAlert(Alert(
title: Text("Connection with desktop stopped"),
message: Text("Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers."),
primaryButton: .default(Text("Ok")),
secondaryButton: .default(Text("Copy error")) { UIPasteboard.general.string = String(describing: rcStopReason) }
))
}
}
} }
if case .connected = sess.sessionState { if case .connected = sess.sessionState {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@@ -2135,13 +1983,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
func switchToLocalSession() { func switchToLocalSession() {
let m = ChatModel.shared let m = ChatModel.shared
let n = NetworkModel.shared
m.remoteCtrlSession = nil m.remoteCtrlSession = nil
do { do {
m.users = try listUsers() m.users = try listUsers()
try getUserChatData() try getUserChatData()
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
} catch let error { } catch let error {
logger.debug("error updating chat data: \(responseError(error))") logger.debug("error updating chat data: \(responseError(error))")
} }
-12
View File
@@ -36,18 +36,6 @@ private func _suspendChat(timeout: Int) {
} }
} }
let seSubscriber = seMessageSubscriber {
switch $0 {
case let .state(state):
switch state {
case .inactive:
if AppChatState.shared.value.inactive { activateChat() }
case .sendingMessage:
if AppChatState.shared.value.canSuspend { suspendChat() }
}
}
}
func suspendChat() { func suspendChat() {
suspendLockQueue.sync { suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout) _suspendChat(timeout: appSuspendTimeout)
+1 -3
View File
@@ -39,7 +39,6 @@ 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
@@ -58,7 +57,6 @@ struct SimpleXApp: App {
} }
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) { switch (phase) {
case .background: case .background:
// --- authentication // --- authentication
@@ -136,7 +134,7 @@ struct SimpleXApp: App {
chatModel.updateChats(with: chats) chatModel.updateChats(with: chats)
if let id = chatModel.chatId, if let id = chatModel.chatId,
let chat = chatModel.getChat(id) { let chat = chatModel.getChat(id) {
Task { await loadChat(chat: chat, clearItems: false) } loadChat(chat: chat)
} }
if let ncr = chatModel.ntfContactRequest { if let ncr = chatModel.ntfContactRequest {
chatModel.ntfContactRequest = nil chatModel.ntfContactRequest = nil
-199
View File
@@ -1,199 +0,0 @@
//
// 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 parsed = UIImage(base64Encoded: image),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
copy.imageFile = filename
return copy
} else {
return ThemeWallpaper()
}
} else {
return self
}
}
func withFilledWallpaperBase64() -> ThemeWallpaper {
let aw = toAppWallpaper()
let type = aw.type
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper (
preset: preset,
scale: scale,
scaleType: scaleType,
background: aw.background?.toReadableHex(),
tint: aw.tint?.toReadableHex(),
image: image,
imageFile: nil
)
}
}
extension ThemeModeOverride {
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
let w: ThemeWallpaper
switch wallpaperType {
case let WallpaperType.preset(filename, scale):
let p = PresetWallpaper.from(filename)
w = ThemeWallpaper(
preset: filename,
scale: scale ?? wallpaper?.scale,
scaleType: nil,
background: p?.background[base]?.toReadableHex(),
tint: p?.tint[base]?.toReadableHex(),
image: nil,
imageFile: nil
)
case WallpaperType.image:
w = ThemeWallpaper(
preset: nil,
scale: nil,
scaleType: WallpaperScaleType.fill,
background: Color.clear.toReadableHex(),
tint: Color.clear.toReadableHex(),
image: nil,
imageFile: nil
)
default:
w = ThemeWallpaper()
}
let wallpaper: ThemeWallpaper? = if let wallpaper {
ThemeWallpaper(
preset: wallpaper.preset,
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
background: wallpaper.background != w.background ? wallpaper.background : nil,
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
image: wallpaper.image,
imageFile: wallpaper.imageFile
)
} else {
nil
}
return ThemeModeOverride(
mode: self.mode,
colors: ThemeColors(
primary: colors.primary != tc.primary ? colors.primary : nil,
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
background: colors.background != tc.background ? colors.background : nil,
surface: colors.surface != tc.surface ? colors.surface : nil,
title: colors.title != tc.title ? colors.title : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
),
wallpaper: wallpaper
)
}
}
-303
View File
@@ -1,303 +0,0 @@
//
// 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 Avently on 24.04.2024. // Created by Stanislav on 24.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved. // Copyright © 2024 SimpleX Chat. All rights reserved.
// //
@@ -11,7 +11,6 @@ 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 {
@@ -44,7 +43,7 @@ struct IncomingCallView: View {
cc.endCall(invitation: invitation) cc.endCall(invitation: invitation)
} }
callButton("Ignore", "multiply", .primary) { callButton("Ignore", "multiply", .accentColor) {
cc.activeCallInvitation = nil cc.activeCallInvitation = nil
} }
@@ -64,7 +63,7 @@ struct IncomingCallView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.modifier(ThemedBackground()) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.onAppear { dismissAllSheets() } .onAppear { dismissAllSheets() }
} }
@@ -77,7 +76,7 @@ struct IncomingCallView: View {
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
Text(text) Text(text)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.frame(minWidth: 44) .frame(minWidth: 44)
}) })
@@ -9,9 +9,10 @@
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
@@ -25,29 +26,29 @@ struct ChatInfoToolbar: View {
ChatInfoImage( ChatInfoImage(
chat: chat, chat: chat,
size: imageSize, size: imageSize,
color: Color(uiColor: .tertiaryLabel) color: colorScheme == .dark
? chatImageColorDark
: chatImageColorLight
) )
.padding(.trailing, 4) .padding(.trailing, 4)
VStack {
let t = Text(cInfo.displayName).font(.headline) let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t) (cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1) .lineLimit(1)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
VStack(spacing: 0) {
v
Text(cInfo.fullName).font(.subheadline) Text(cInfo.fullName).font(.subheadline)
.lineLimit(1) .lineLimit(1)
.padding(.top, -2)
} }
} }
} }
.foregroundColor(theme.colors.onBackground) .foregroundColor(.primary)
.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(theme.colors.secondary) .foregroundColor(.secondary)
.baselineOffset(1) .baselineOffset(1)
.kerning(-2) .kerning(-2)
} }
@@ -56,6 +57,5 @@ 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())
} }
} }
+67 -617
View File
@@ -36,14 +36,14 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
} }
} }
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String], _ secondaryColor: Color) -> some View { @ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> 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(secondaryColor) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -90,21 +90,16 @@ enum SendReceipts: Identifiable, Hashable {
struct ChatInfoView: View { struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var networkModel = NetworkModel.shared
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@State var contact: Contact @State var contact: Contact
@Binding var connectionStats: ConnectionStats?
@Binding var customUserProfile: Profile?
@State var localAlias: String @State var localAlias: String
var onSearch: () -> Void @Binding var connectionCode: String?
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@State private var connectionCode: String? = nil
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil @State private var alert: ChatInfoViewAlert? = nil
@State private var actionSheet: SomeActionSheet? = nil @State private var showDeleteContactActionSheet = false
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true @State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -116,8 +111,7 @@ struct ChatInfoView: View {
case abortSwitchAddressAlert case abortSwitchAddressAlert
case syncConnectionForceAlert case syncConnectionForceAlert
case queueInfo(info: String) case queueInfo(info: String)
case someAlert(alert: SomeAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String { var id: String {
switch self { switch self {
@@ -127,7 +121,6 @@ struct ChatInfoView: View {
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 .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
} }
} }
@@ -148,26 +141,9 @@ struct ChatInfoView: View {
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.padding(.bottom, 18)
GeometryReader { g in
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
}
}
.padding(.trailing)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
if let customUserProfile = customUserProfile { if let customUserProfile = customUserProfile {
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { Section("Incognito") {
HStack { HStack {
Text("Your random profile") Text("Your random profile")
Spacer() Spacer()
@@ -178,7 +154,6 @@ struct ChatInfoView: View {
} }
Section { Section {
Group {
if let code = connectionCode { verifyCodeButton(code) } if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton() contactPreferencesButton()
sendReceiptsOption() sendReceiptsOption()
@@ -186,19 +161,9 @@ struct ChatInfoView: View {
connStats.ratchetSyncAllowed { connStats.ratchetSyncAllowed {
synchronizeConnectionButton() synchronizeConnectionButton()
} }
// } else if developerTools { // } else if developerTools {
// synchronizeConnectionButtonForce() // synchronizeConnectionButtonForce()
// } // }
}
.disabled(!contact.ready || !contact.active)
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
.disabled(!contact.ready || !contact.active) .disabled(!contact.ready || !contact.active)
@@ -218,15 +183,13 @@ 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(header: Text("Servers").foregroundColor(theme.colors.secondary)) { Section("Servers") {
networkStatusRow() networkStatusRow()
.onTapGesture { .onTapGesture {
alert = .networkStatusAlert alert = .networkStatusAlert
@@ -248,8 +211,8 @@ struct ChatInfoView: View {
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
} }
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
} }
} }
} }
@@ -260,7 +223,7 @@ struct ChatInfoView: View {
} }
if developerTools { if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { Section(header: Text("For console")) {
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") { Button ("Debug delivery") {
@@ -278,7 +241,6 @@ struct ChatInfoView: View {
} }
} }
} }
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true) .navigationBarHidden(true)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -287,24 +249,6 @@ struct ChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsContacts sendReceiptsUserDefault = currentUser.sendRcptsContacts
} }
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
}
} }
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
switch(alertItem) { switch(alertItem) {
@@ -314,30 +258,41 @@ struct ChatInfoView: View {
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 .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return mkAlert(title: title, message: error)
} }
} }
.actionSheet(item: $actionSheet) { $0.actionSheet } .actionSheet(isPresented: $showDeleteContactActionSheet) {
.sheet(item: $sheet) { if contact.ready && contact.active {
if #available(iOS 16.0, *) { return ActionSheet(
$0.content title: Text("Delete contact?\nThis cannot be undone!"),
.presentationDetents([.fraction(0.4)]) buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
.destructive(Text("Delete")) { deleteContact(notify: false) },
.cancel()
]
)
} else { } else {
$0.content return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact() },
.cancel()
]
)
} }
} }
} }
private func contactInfoHeader() -> some View { private func contactInfoHeader() -> some View {
VStack(spacing: 8) { VStack {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.vertical, 12) .padding(.top, 12)
.padding()
if contact.verified { if contact.verified {
( (
Text(Image(systemName: "checkmark.shield")) Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.font(.title2) .font(.title2)
+ Text(" ") + Text(" ")
+ Text(contact.profile.displayName) + Text(contact.profile.displayName)
@@ -377,7 +332,7 @@ struct ChatInfoView: View {
setContactAlias() setContactAlias()
} }
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func setContactAlias() { private func setContactAlias() {
@@ -394,25 +349,6 @@ struct ChatInfoView: View {
} }
} }
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!contact.ready || chat.chatItems.isEmpty)
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
}
.disabled(!contact.ready || !contact.active)
}
private func verifyCodeButton(_ code: String) -> some View { private func verifyCodeButton(_ code: String) -> some View {
NavigationLink { NavigationLink {
VerifyCodeView( VerifyCodeView(
@@ -434,7 +370,6 @@ 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",
@@ -451,7 +386,6 @@ 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")
@@ -500,34 +434,27 @@ struct ChatInfoView: View {
HStack { HStack {
Text("Network status") Text("Network status")
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.font(.system(size: 14)) .font(.system(size: 14))
Spacer() Spacer()
Text(networkModel.contactNetworkStatus(contact).statusString) Text(chatModel.contactNetworkStatus(contact).statusString)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
serverImage() serverImage()
} }
} }
private func serverImage() -> some View { private func serverImage() -> some View {
let status = networkModel.contactNetworkStatus(contact) let status = chatModel.contactNetworkStatus(contact)
return Image(systemName: status.imageName) return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : theme.colors.secondary) .foregroundColor(status == .connected ? .green : .secondary)
.font(.system(size: 12)) .font(.system(size: 12))
} }
private func deleteContactButton() -> some View { private func deleteContactButton() -> some View {
Button(role: .destructive) { Button(role: .destructive) {
deleteContactDialog( showDeleteContactActionSheet = true
chat,
contact,
dismissToChatList: true,
showAlert: { alert = .someAlert(alert: $0) },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
Label("Delete contact", systemImage: "person.badge.minus") Label("Delete contact", systemImage: "trash")
.foregroundColor(Color.red) .foregroundColor(Color.red)
} }
} }
@@ -541,6 +468,25 @@ struct ChatInfoView: View {
} }
} }
private func deleteContact(notify: Bool? = nil) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
}
}
private func clearChatAlert() -> Alert { private func clearChatAlert() -> Alert {
Alert( Alert(
title: Text("Clear conversation?"), title: Text("Clear conversation?"),
@@ -558,7 +504,7 @@ struct ChatInfoView: View {
private func networkStatusAlert() -> Alert { private func networkStatusAlert() -> Alert {
Alert( Alert(
title: Text("Network status"), title: Text("Network status"),
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation) message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
) )
} }
@@ -619,295 +565,6 @@ struct ChatInfoView: View {
} }
} }
struct AudioCallButton: View {
var chat: Chat
var contact: Contact
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
CallButton(
chat: chat,
contact: contact,
image: "phone.fill",
title: "call",
mediaType: .audio,
width: width,
showAlert: showAlert
)
}
}
struct VideoButton: View {
var chat: Chat
var contact: Contact
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
CallButton(
chat: chat,
contact: contact,
image: "video.fill",
title: "video",
mediaType: .video,
width: width,
showAlert: showAlert
)
}
}
private struct CallButton: View {
var chat: Chat
var contact: Contact
var image: String
var title: LocalizedStringKey
var mediaType: CallMediaType
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall {
CallController.shared.startCall(contact, mediaType)
} else if contact.nextSendGrpInv {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Send message to enable calls."
),
id: "can't call contact, send message"
))
} else if !contact.active {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Contact is deleted."
),
id: "can't call contact, contact deleted"
))
} else if !contact.ready {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Connecting to contact, please wait or check later!"
),
id: "can't call contact, contact not ready"
))
} else if !chat.chatInfo.featureEnabled(.calls) {
switch chat.chatInfo.showEnableCallsAlert {
case .userEnable:
showAlert(SomeAlert(
alert: Alert(
title: Text("Allow calls?"),
message: Text("You need to allow your contact to call to be able to call them."),
primaryButton: .default(Text("Allow")) {
allowFeatureToContact(contact, .calls)
},
secondaryButton: .cancel()
),
id: "allow calls"
))
case .askContact:
showAlert(SomeAlert(
alert: mkAlert(
title: "Calls prohibited!",
message: "Please ask your contact to enable calls."
),
id: "calls prohibited, ask contact"
))
case .other:
showAlert(SomeAlert(
alert: mkAlert(
title: "Calls prohibited!",
message: "Please check yours and your contact preferences."
)
, id: "calls prohibited, other"
))
}
} else {
showAlert(SomeAlert(
alert: mkAlert(title: "Can't call contact"),
id: "can't call contact"
))
}
}
.disabled(ChatModel.shared.activeCall != nil)
}
}
let infoViewActionButtonHeight: CGFloat = 60
struct InfoViewButton: View {
var image: String
var title: LocalizedStringKey
var disabledLook: Bool = false
var width: CGFloat
var action: () -> Void
var body: some View {
VStack(spacing: 4) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
Text(title)
.font(.caption)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.accentColor)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(10.0)
.frame(width: width, height: infoViewActionButtonHeight)
.disabled(disabledLook)
.onTapGesture(perform: action)
}
}
struct ChatWallpaperEditorSheet: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var globalThemeUsed: Bool = false
@State var chat: Chat
@State private var themes: ThemeModeOverrides
init(chat: Chat) {
self.chat = chat
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
}
}
var body: some View {
let preferred = themes.preferredMode(!theme.colors.isLight)
let initialTheme = preferred ?? ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
ChatWallpaperEditor(
initialTheme: initialTheme,
themeModeOverride: initialTheme,
applyToMode: themes.light == themes.dark ? nil : initialTheme.mode,
globalThemeUsed: $globalThemeUsed,
save: { applyToMode, newTheme in
await save(applyToMode, newTheme, $chat)
}
)
.navigationTitle("Chat theme")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
globalThemeUsed = preferred == nil
}
.onChange(of: theme.base.mode) { _ in
globalThemeUsed = themesFromChat(chat).preferredMode(!theme.colors.isLight) == nil
}
.onChange(of: ChatModel.shared.chatId) { _ in
dismiss()
}
}
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
}
}
private static var updateBackendTask: Task = Task {}
private func save(
_ applyToMode: DefaultThemeMode?,
_ newTheme: ThemeModeOverride?,
_ chat: Binding<Chat>
) async {
let unchangedThemes: ThemeModeOverrides = themesFromChat(chat.wrappedValue)
var wallpaperFiles = Set([unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile])
var changedThemes: ThemeModeOverrides? = unchangedThemes
let light: ThemeModeOverride? = if let newTheme {
ThemeModeOverride(mode: DefaultThemeMode.light, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
} else {
nil
}
let dark: ThemeModeOverride? = if let newTheme {
ThemeModeOverride(mode: DefaultThemeMode.dark, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
} else {
nil
}
if let applyToMode {
switch applyToMode {
case DefaultThemeMode.light:
changedThemes?.light = light
case DefaultThemeMode.dark:
changedThemes?.dark = dark
}
} else {
changedThemes?.light = light
changedThemes?.dark = dark
}
if changedThemes?.light != nil || changedThemes?.dark != nil {
let light = changedThemes?.light
let dark = changedThemes?.dark
let currentMode = CurrentColors.base.mode
// same image file for both modes, copy image to make them as different files
if var light, var dark, let lightWallpaper = light.wallpaper, let darkWallpaper = dark.wallpaper, let lightImageFile = lightWallpaper.imageFile, let darkImageFile = darkWallpaper.imageFile, lightWallpaper.imageFile == darkWallpaper.imageFile {
let imageFile = if currentMode == DefaultThemeMode.light {
darkImageFile
} else {
lightImageFile
}
let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile))
if currentMode == DefaultThemeMode.light {
dark.wallpaper?.imageFile = filePath
changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark)
} else {
light.wallpaper?.imageFile = filePath
changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark)
}
}
} else {
changedThemes = nil
}
wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile)
wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile)
wallpaperFiles.forEach(removeWallpaperFile)
let changedThemesConstant = changedThemes
ChatWallpaperEditorSheet.updateBackendTask.cancel()
ChatWallpaperEditorSheet.updateBackendTask = Task {
do {
try await Task.sleep(nanoseconds: 300_000000)
if await apiSetChatUIThemes(chatId: chat.id, themes: changedThemesConstant) {
if case var ChatInfo.direct(contact) = chat.wrappedValue.chatInfo {
contact.uiThemes = changedThemesConstant
await MainActor.run {
ChatModel.shared.updateChatInfo(ChatInfo.direct(contact: contact))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
themes = themesFromChat(chat.wrappedValue)
}
} else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
groupInfo.uiThemes = changedThemesConstant
await MainActor.run {
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
themes = themesFromChat(chat.wrappedValue)
}
}
}
} catch {
// canceled task
}
}
}
}
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert { func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
Alert( Alert(
title: Text("Change receiving address?"), title: Text("Change receiving address?"),
@@ -951,222 +608,15 @@ func queueInfoAlert(_ info: String) -> Alert {
) )
} }
func deleteContactDialog(
_ chat: Chat,
_ contact: Contact,
dismissToChatList: Bool,
showAlert: @escaping (SomeAlert) -> Void,
showActionSheet: @escaping (SomeActionSheet) -> Void,
showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
) {
if contact.sndReady && contact.active && !contact.chatDeleted {
deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent)
} else if contact.sndReady && contact.active && contact.chatDeleted {
deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet)
} else { // !(contact.sndReady && contact.active)
deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet)
}
}
private func deleteContactOrConversationDialog(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void,
_ showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Delete contact?"),
buttons: [
.destructive(Text("Only delete conversation")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
},
.destructive(Text("Delete contact")) {
showSheetContent(SomeSheet(
content: { AnyView(
DeleteActiveContactDialog(
chat: chat,
contact: contact,
dismissToChatList: dismissToChatList,
showAlert: showAlert
)
) },
id: "DeleteActiveContactDialog"
))
},
.cancel()
]
),
id: "deleteContactOrConversationDialog"
))
}
private func deleteContactMaybeErrorAlert(
_ chat: Chat,
_ contact: Contact,
chatDeleteMode: ChatDeleteMode,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void
) {
Task {
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
if let alert = alert_ {
showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
} else {
if dismissToChatList {
await MainActor.run {
ChatModel.shared.chatId = nil
}
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
AlertManager.shared.showAlert(deleteConversationNotice(contact))
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
}
} else {
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice"))
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice"))
}
}
}
}
}
private func deleteConversationNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Conversation deleted!"),
message: Text("You can send messages to \(contact.displayName) from Archived contacts."),
primaryButton: .default(Text("Don't show again")) {
showDeleteConversationNoticeDefault.set(false)
},
secondaryButton: .default(Text("Ok"))
)
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the list of chats."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNoticeDefault.set(false)
},
secondaryButton: .default(Text("Ok"))
)
}
enum ContactDeleteMode {
case full
case entity
public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
switch self {
case .full: .full(notify: notify)
case .entity: .entity(notify: notify)
}
}
}
struct DeleteActiveContactDialog: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
var chat: Chat
var contact: Contact
var dismissToChatList: Bool
var showAlert: (SomeAlert) -> Void
@State private var keepConversation = false
var body: some View {
NavigationView {
List {
Section {
Toggle("Keep conversation", isOn: $keepConversation)
Button(role: .destructive) {
dismiss()
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert)
} label: {
Text("Delete without notification")
}
Button(role: .destructive) {
dismiss()
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert)
} label: {
Text("Delete and notify contact")
}
} footer: {
Text("Contact will be deleted - this cannot be undone!")
.foregroundColor(theme.colors.secondary)
}
}
.modifier(ThemedBackground(grouped: true))
}
}
var contactDeleteMode: ContactDeleteMode {
keepConversation ? .entity : .full
}
}
private func deleteContactWithoutConversation(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
buttons: [
.destructive(Text("Delete and notify contact")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert)
},
.destructive(Text("Delete without notification")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
},
.cancel()
]
),
id: "deleteContactWithoutConversation"
))
}
private func deleteNotReadyContact(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
buttons: [
.destructive(Text("Confirm")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
},
.cancel()
]
),
id: "deleteNotReadyContact"
))
}
struct ChatInfoView_Previews: PreviewProvider { struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ChatInfoView( ChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData, contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
customUserProfile: Binding.constant(nil),
localAlias: "", localAlias: "",
onSearch: {} connectionCode: Binding.constant(nil)
) )
} }
} }
@@ -9,7 +9,6 @@ import SwiftUI
class AnimatedImageView: UIView { class AnimatedImageView: UIView {
var image: UIImage? = nil var image: UIImage? = nil
var imageView: UIImageView? = nil var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
fatalError("Not implemented") fatalError("Not implemented")
} }
convenience init(image: UIImage, contentMode: UIView.ContentMode) { convenience init(image: UIImage) {
self.init() self.init()
self.image = image self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
} }
@@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView { if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData { if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
subview.removeFromSuperview() subview.removeFromSuperview()
} }
@@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable { struct SwiftyGif: UIViewRepresentable {
private let image: UIImage private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) { init(image: UIImage) {
self.image = image self.image = image
self.contentMode = contentMode
} }
func makeUIView(context: Context) -> AnimatedImageView { func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image, contentMode: contentMode) AnimatedImageView(image: image)
} }
func updateUIView(_ imageView: AnimatedImageView, context: Context) { func updateUIView(_ imageView: AnimatedImageView, context: Context) {
@@ -11,7 +11,6 @@ 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
@@ -23,7 +22,7 @@ struct CICallItemView: View {
switch status { switch status {
case .pending: case .pending:
if sent { if sent {
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary) Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
} else { } else {
acceptCallButton() acceptCallButton()
} }
@@ -36,7 +35,7 @@ struct CICallItemView: View {
case .error: missedCallIcon(sent).foregroundColor(.orange) case .error: missedCallIcon(sent).foregroundColor(.orange)
} }
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false) CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
.padding(.bottom, 8) .padding(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -53,7 +52,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(theme.colors.secondary) Text(durationText(duration)).foregroundColor(.secondary)
} }
} }
@@ -71,7 +70,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(theme.colors.secondary) Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
} }
} }
} }
@@ -11,9 +11,7 @@ import SimpleXChat
struct CIChatFeatureView: View { struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool @Binding var revealed: Bool
var feature: Feature var feature: Feature
@@ -54,8 +52,8 @@ struct CIChatFeatureView: View {
var fs: [FeatureInfo] = [] var fs: [FeatureInfo] = []
var icons: Set<String> = [] var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) { if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count, while i < m.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) { let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) { if !icons.contains(f.icon) {
fs.insert(f, at: 0) fs.insert(f, at: 0)
icons.insert(f.icon) icons.insert(f.icon)
@@ -68,10 +66,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(theme.colors.secondary), param) case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param) case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(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 .sndGroupFeature(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)
default: nil default: nil
} }
} }
@@ -83,7 +81,7 @@ struct CIChatFeatureView: View {
if let param = f.param { if let param = f.param {
HStack { HStack {
i i
chatEventText(Text(param), theme.colors.secondary).lineLimit(1) chatEventText(Text(param)).lineLimit(1)
} }
} else { } else {
i i
@@ -95,7 +93,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, theme.colors.secondary) chatEventText(chatItem)
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -106,6 +104,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(.secondary)) CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
} }
} }
@@ -11,7 +11,6 @@ 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
@@ -20,7 +19,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(theme.colors.secondary) .foregroundColor(.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) {
@@ -41,17 +40,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(theme.colors.secondary) .foregroundColor(.secondary)
if let acceptText { if let acceptText {
r = r r = r
+ Text(acceptText) + Text(acceptText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
+ Text(" ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
return r.font(.caption) return r.font(.caption)
} }
} }
@@ -11,16 +11,11 @@ import SimpleXChat
struct CIFileView: View { struct CIFileView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let file: CIFile? let file: CIFile?
let edited: Bool let edited: Bool
var smallViewSize: CGFloat?
var body: some View { var body: some View {
if smallViewSize != nil {
fileIndicator()
.onTapGesture(perform: fileAction)
} else {
let metaReserve = edited let metaReserve = edited
? " " ? " "
: " " : " "
@@ -35,12 +30,12 @@ struct CIFileView: View {
Text(file.fileName) Text(file.fileName)
.lineLimit(1) .lineLimit(1)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground) .foregroundColor(.primary)
Text(prettyFileSize + metaReserve) Text(prettyFileSize + metaReserve)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} else { } else {
Text(metaReserve) Text(metaReserve)
@@ -53,7 +48,6 @@ struct CIFileView: View {
} }
.disabled(!itemInteractive) .disabled(!itemInteractive)
} }
}
private var itemInteractive: Bool { private var itemInteractive: Bool {
if let file = file { if let file = file {
@@ -62,16 +56,14 @@ struct CIFileView: View {
case .sndTransfer: return false case .sndTransfer: return false
case .sndComplete: return true case .sndComplete: return true
case .sndCancelled: return false case .sndCancelled: return false
case .sndError: return true case .sndError: return false
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 true case .rcvError: return false
case .rcvWarning: return true
case .invalid: return false case .invalid: return false
} }
} }
@@ -116,18 +108,6 @@ 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) {
@@ -138,18 +118,6 @@ struct CIFileView: View {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource) 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
} }
} }
@@ -173,10 +141,9 @@ 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: theme.colors.primary) fileIcon("arrow.down.doc.fill", color: .accentColor)
} else { } else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
} }
@@ -188,11 +155,10 @@ struct CIFileView: View {
progressView() progressView()
} }
case .rcvAborted: case .rcvAborted:
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12) fileIcon("doc.fill", color: .accentColor, 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 {
@@ -201,22 +167,21 @@ struct CIFileView: View {
} }
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View { private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
let size = smallViewSize ?? 30 ZStack(alignment: .center) {
return ZStack(alignment: .center) {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: 30, height: 30)
.foregroundColor(color) .foregroundColor(color)
if let innerIcon = innerIcon, if let innerIcon = innerIcon,
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) { let innerIconSize = innerIconSize {
Image(systemName: innerIcon) Image(systemName: innerIcon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(maxHeight: 16) .frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize) .frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.top, size / 2.5) .padding(.top, 12)
} }
} }
} }
@@ -11,7 +11,7 @@ import SimpleXChat
struct CIGroupInvitationView: View { struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@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 ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.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,11 +65,12 @@ struct CIGroupInvitationView: View {
} }
} }
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false) CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, colorScheme))
.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
@@ -98,7 +99,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View { private func groupInfoView(_ action: Bool) -> some View {
var color: Color var color: Color
if action && !inProgress { if action && !inProgress {
color = chatIncognito ? .indigo : theme.colors.primary color = chatIncognito ? .indigo : .accentColor
} else { } else {
color = Color(uiColor: .tertiaryLabel) color = Color(uiColor: .tertiaryLabel)
} }
@@ -11,37 +11,29 @@ 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
var preview: UIImage? let image: String
let maxWidth: CGFloat let maxWidth: CGFloat
var imgWidth: CGFloat? @Binding var imgWidth: CGFloat?
var smallView: Bool = false @State var scrollProxy: ScrollViewProxy?
@Binding var showFullScreenImage: Bool @State private var showFullScreenImage = false
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View { var body: some View {
let file = chatItem.file let file = chatItem.file
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) { if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } } imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) { .fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage) FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
} }
.onTapGesture { showFullScreenImage = true } .onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false showFullScreenImage = false
} }
} else if let preview { } else if let data = Data(base64Encoded: dropImagePrefix(image)),
Group { let uiImage = UIImage(data: data) {
if smallView { imageView(uiImage)
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
.onTapGesture { .onTapGesture {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
@@ -68,39 +60,17 @@ struct CIImageView: View {
case .rcvTransfer: () // ? case .rcvTransfer: () // ?
case .rcvComplete: () // ? case .rcvComplete: () // ?
case .rcvCancelled: () // TODO case .rcvCancelled: () // TODO
case let .rcvError(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
case let .sndError(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: () default: ()
} }
} }
} }
} }
} }
.onDisappear {
showFullScreenImage = false
}
} }
private func imageView(_ img: UIImage) -> some View { private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { imgWidth = w }
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
if img.imageData == nil { if img.imageData == nil {
Image(uiImage: img) Image(uiImage: img)
@@ -112,28 +82,9 @@ struct CIImageView: View {
.frame(width: w, height: w * img.size.height / img.size.width) .frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit() .scaledToFit()
} }
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
loadingIndicator() loadingIndicator()
} }
} }
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
}
}
@ViewBuilder private func loadingIndicator() -> some View { @ViewBuilder private func loadingIndicator() -> some View {
if let file = chatItem.file { if let file = chatItem.file {
@@ -148,16 +99,14 @@ 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()
} }
} }
} }
@@ -178,12 +127,4 @@ struct CIImageView: View {
.tint(.white) .tint(.white)
.padding(8) .padding(8)
} }
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
} }
@@ -9,7 +9,6 @@
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
@@ -22,6 +21,7 @@ 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 }
.appSheet(isPresented: $showJSON) { .appSheet(isPresented: $showJSON) {
@@ -44,7 +44,6 @@ 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,17 +10,16 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CILinkView: View { struct CILinkView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let linkPreview: LinkPreview let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) { if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title) Text(linkPreview.title)
@@ -33,7 +32,7 @@ struct CILinkView: View {
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -11,7 +11,6 @@ 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 {
@@ -44,12 +43,12 @@ struct CIMemberCreatedContactView: View {
r = r r = r
+ Text(openText) + Text(openText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
+ Text(" ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
return r.font(.caption) return r.font(.caption)
} }
@@ -57,11 +56,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(theme.colors.secondary) .foregroundColor(.secondary)
} else { } else {
return Text(chatItem.content.text + " ") return Text(chatItem.content.text + " ")
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
@@ -11,9 +11,8 @@ 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 var metaColor = Color.secondary
var paleMetaColor = Color(UIColor.tertiaryLabel) var paleMetaColor = Color(UIColor.tertiaryLabel)
var showStatus = true var showStatus = true
var showEdited = true var showEdited = true
@@ -64,7 +63,6 @@ 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,
@@ -87,7 +85,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, primaryColor) { if let (icon, statusColor) = meta.statusIcon(color) {
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))
@@ -114,16 +112,15 @@ 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)), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), 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", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), 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: .partial)), 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: .badMsgHash, sndProgress: .complete)), 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", .sndSent(sndProgress: .complete), itemEdited: true), 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.getDeletedContentSample(), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
} }
.previewLayout(.fixed(width: 360, height: 100)) .previewLayout(.fixed(width: 360, height: 100))
} }
@@ -13,7 +13,6 @@ 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
@@ -27,7 +26,7 @@ struct CIRcvDecryptionError: View {
case syncNotSupportedContactAlert case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert case syncNotSupportedMemberAlert
case decryptionErrorAlert case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@@ -62,7 +61,7 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message()) case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message()) case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message()) case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
} }
@@ -115,23 +114,24 @@ struct CIRcvDecryptionError: View {
} }
( (
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")) Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ Text("Fix connection") + Text("Fix connection")
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? .accentColor : .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, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.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,12 +145,13 @@ 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, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.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,51 +13,61 @@ 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 preview: UIImage? private let image: String
@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
private var videoWidth: CGFloat? @Binding private var videoWidth: CGFloat?
private let smallView: Bool @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?
@State private var urlDecrypted: URL? @State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false @State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool @State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil @State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil @State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) { init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
self.chatItem = chatItem self.chatItem = chatItem
self.preview = preview self.image = image
self._duration = State(initialValue: duration) self._duration = State(initialValue: duration)
self.maxWidth = maxWidth self.maxWidth = maxWidth
self.videoWidth = videoWidth self._videoWidth = videoWidth
self.smallView = smallView self.scrollProxy = scrollProxy
self._showFullScreenPlayer = showFullscreenPlayer if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
if let decrypted = decrypted {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
}
self._url = State(initialValue: url)
}
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
self._preview = State(initialValue: uiImage)
}
} }
var body: some View { var body: some View {
let file = chatItem.file let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) { ZStack {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView { if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
smallVideoView(decrypted, file, preview)
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
videoView(player, decrypted, file, preview, duration) videoView(player, decrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
smallVideoViewEncrypted(file, defaultPreview)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration) videoViewEncrypted(file, defaultPreview, duration)
} else if let preview, let file { } else if let data = Data(base64Encoded: dropImagePrefix(image)),
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } let uiImage = UIImage(data: data) {
imageView(uiImage)
.onTapGesture { .onTapGesture {
if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
@@ -82,63 +92,20 @@ struct CIVideoView: View {
} }
} }
} }
if !smallView { }
durationProgress() durationProgress()
} }
} if let file = file, showDownloadButton(file.fileStatus) {
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView {
Button { Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: { } label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
} }
} else if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} }
} }
} }
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
}
private func setupPlayer(_ file: CIFile?) { private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus { switch fileStatus {
case .rcvInvitation: true case .rcvInvitation: true
case .rcvAborted: true case .rcvAborted: true
@@ -151,6 +118,11 @@ struct CIVideoView: View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview) imageView(defaultPreview)
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onTapGesture { .onTapGesture {
decrypt(file: file) { decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil showFullScreenPlayer = urlDecrypted != nil
@@ -159,7 +131,6 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !blurred {
if !decryptionInProgress { if !decryptionInProgress {
Button { Button {
decrypt(file: file) { decrypt(file: file) {
@@ -178,10 +149,10 @@ 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)
@@ -193,7 +164,9 @@ struct CIVideoView: View {
videoPlaying = false videoPlaying = false
} }
} }
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) .fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture { .onTapGesture {
switch player.timeControlStatus { switch player.timeControlStatus {
case .playing: case .playing:
@@ -209,7 +182,7 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !videoPlaying && !blurred { if !videoPlaying {
Button { Button {
m.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
player.play() player.play()
@@ -219,7 +192,7 @@ struct CIVideoView: View {
.disabled(!canBePlayed) .disabled(!canBePlayed)
} }
} }
fileStatusIcon() loadingIndicator()
} }
.onAppear { .onAppear {
addObserver(player, url) addObserver(player, url)
@@ -231,53 +204,14 @@ struct CIVideoView: View {
} }
} }
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture {
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12) .frame(width: 12, height: 12)
.foregroundColor(color) .foregroundColor(color)
.padding(.leading, smallView ? 0 : 4) .padding(.leading, 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier) .frame(width: 40, height: 40)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .clipShape(Circle())
} }
@@ -285,9 +219,9 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View { private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12) .frame(width: 12, height: 12)
.tint(color) .tint(color)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40) .frame(width: 40, height: 40)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .clipShape(Circle())
} }
@@ -318,32 +252,17 @@ 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)
.modifier(PrivacyBlur(blurred: $blurred)) loadingIndicator()
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
} }
} }
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View { @ViewBuilder private func loadingIndicator() -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
}
}
@ViewBuilder private func fileStatusIcon() -> some View {
if let file = chatItem.file { if let file = chatItem.file {
switch file.fileStatus { switch file.fileStatus {
case .sndStored: case .sndStored:
@@ -360,22 +279,7 @@ 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 let .sndError(sndFileError): case .sndError: fileIcon("xmark", 10, 13)
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):
@@ -385,25 +289,10 @@ 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 let .rcvError(rcvFileError): case .rcvError: fileIcon("xmark", 10, 13)
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()
} }
} }
} }
@@ -414,7 +303,7 @@ struct CIVideoView: View {
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(.white) .foregroundColor(.white)
.padding(smallView ? 0 : padding) .padding(padding)
} }
private func progressView() -> some View { private func progressView() -> some View {
@@ -422,7 +311,7 @@ struct CIVideoView: View {
.progressViewStyle(.circular) .progressViewStyle(.circular)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.tint(.white) .tint(.white)
.padding(smallView ? 0 : 11) .padding(11)
} }
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View { private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
@@ -434,7 +323,7 @@ struct CIVideoView: View {
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11) .padding([.trailing, .top], 11)
} }
// TODO encrypt: where file size is checked? // TODO encrypt: where file size is checked?
@@ -474,8 +363,7 @@ struct CIVideoView: View {
) )
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) { DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+ m.stopPreviousRecPlay = url
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
if let player = fullPlayer { if let player = fullPlayer {
player.play() player.play()
var played = false var played = false
@@ -512,12 +400,10 @@ struct CIVideoView: View {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete) urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run { await MainActor.run {
if let decrypted = urlDecrypted { if let decrypted = urlDecrypted {
if !smallView {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false) player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
fullPlayer = AVPlayer(url: decrypted) fullPlayer = AVPlayer(url: decrypted)
} }
decryptionInProgress = false decryptionInProgress = true
completed?() completed?()
} }
} }
@@ -11,30 +11,18 @@ 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
@State var audioPlayer: AudioPlayer? = nil @Binding var audioPlayer: AudioPlayer?
@State var playbackState: VoiceMessagePlaybackState = .noPlayback @Binding var playbackState: VoiceMessagePlaybackState
@State var playbackTime: TimeInterval? = nil @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
Group { Group {
if smallViewSize != nil { if chatItem.chatDir.sent {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) { VStack (alignment: .trailing, spacing: 6) {
HStack { HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -65,13 +53,7 @@ struct CIVoiceView: View {
} }
private func player() -> some View { private func player() -> some View {
let sizeMultiplier: CGFloat = if let sz = smallViewSize { VoiceMessagePlayer(
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@@ -80,8 +62,7 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: sizeMultiplier
) )
} }
@@ -91,7 +72,7 @@ struct CIVoiceView: View {
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime playbackTime: $playbackTime
) )
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func playbackSlider() -> some View { private func playbackSlider() -> some View {
@@ -108,11 +89,10 @@ 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, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
} }
} }
@@ -137,9 +117,8 @@ struct VoiceMessagePlayerTime: View {
} }
struct VoiceMessagePlayer: View { struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem var chatItem: ChatItem
var recordingFile: CIFile? var recordingFile: CIFile?
var recordingTime: TimeInterval var recordingTime: TimeInterval
@@ -149,61 +128,24 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer? @Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval? @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View { var body: some View {
ZStack { ZStack {
if let recordingFile = recordingFile { if let recordingFile = recordingFile {
switch recordingFile.fileStatus { switch recordingFile.fileStatus {
case .sndStored: case .sndStored: playbackButton()
if recordingFile.fileProtocol == .local { case .sndTransfer: playbackButton()
playbackButton()
} else {
loadingIcon()
}
case .sndTransfer: loadingIcon()
case .sndComplete: playbackButton() case .sndComplete: playbackButton()
case .sndCancelled: playbackButton() case .sndCancelled: playbackButton()
case let .sndError(sndFileError): case .sndError: playbackButton()
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 let .rcvError(rcvFileError): case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
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 {
@@ -211,88 +153,27 @@ struct VoiceMessagePlayer: View {
} }
} }
.onAppear { .onAppear {
if audioPlayer == nil {
let small = sizeMultiplier != 1
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
}
seek = { to in audioPlayer?.seek(to) } seek = { to in audioPlayer?.seek(to) }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) { audioPlayer?.onTimer = { playbackTime = $0 }
getAppFilePath(recordingSource.filePath)
} else {
nil
}
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
audioPlayer?.onTimer = {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
}
audioPlayer?.onFinishPlayback = { audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
notifyStateChange()
}
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
stopPlayback()
} }
} }
.onChange(of: chatModel.stopPreviousRecPlay) { it in .onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath, if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
stopPlayback() audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} }
} }
.onChange(of: playbackState) { state in .onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback allowMenu = state == .paused || state == .noPlayback
// Notify activeContentPreview in ChatPreviewView that playback is finished
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay = nil
}
}
.onChange(of: chatModel.chatId) { _ in
stopPlayback()
}
.onDisappear {
if sizeMultiplier == 1 && chatModel.chatId == nil {
stopPlayback()
}
} }
} }
@ViewBuilder private func playbackButton() -> some View { @ViewBuilder private func playbackButton() -> some View {
if sizeMultiplier != 1 {
switch playbackState {
case .noPlayback:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
}
case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
}
case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
}
}
} else {
switch playbackState { switch playbackState {
case .noPlayback: case .noPlayback:
Button { Button {
@@ -300,58 +181,44 @@ struct VoiceMessagePlayer: View {
startPlayback(recordingSource) startPlayback(recordingSource)
} }
} label: { } label: {
playPauseIcon("play.fill", theme.colors.primary) playPauseIcon("play.fill")
} }
case .playing: case .playing:
Button { Button {
audioPlayer?.pause() audioPlayer?.pause()
playbackState = .paused playbackState = .paused
notifyStateChange()
} label: { } label: {
playPauseIcon("pause.fill", theme.colors.primary) playPauseIcon("pause.fill")
} }
case .paused: case .paused:
Button { Button {
audioPlayer?.play() audioPlayer?.play()
playbackState = .playing playbackState = .playing
notifyStateChange()
} label: { } label: {
playPauseIcon("play.fill", theme.colors.primary) playPauseIcon("play.fill")
}
} }
} }
} }
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
ZStack { ZStack {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier) .frame(width: 20, height: 20)
.foregroundColor(color) .foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0) .padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
if recordingTime > 0 { if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime) ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter .frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
} }
} }
} }
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
Group {
if sizeMultiplier != 1 {
playPauseIcon(icon, theme.colors.primary)
.onTapGesture {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
} else {
Button { Button {
Task { Task {
if let user = chatModel.currentUser { if let user = chatModel.currentUser {
@@ -359,22 +226,11 @@ struct VoiceMessagePlayer: View {
} }
} }
} label: { } label: {
playPauseIcon(icon, theme.colors.primary) playPauseIcon(icon)
}
}
}
}
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} }
} }
private struct ProgressCircle: View { private struct ProgressCircle: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
@@ -382,7 +238,7 @@ struct VoiceMessagePlayer: View {
Circle() Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length)) .trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke( .stroke(
theme.colors.primary, Color.accentColor,
style: StrokeStyle(lineWidth: 3) style: StrokeStyle(lineWidth: 3)
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
@@ -390,100 +246,26 @@ struct VoiceMessagePlayer: View {
} }
} }
private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func loadingIcon() -> some View { private func loadingIcon() -> some View {
ProgressView() ProgressView()
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier) .frame(width: 30, height: 30)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
} }
private func startPlayback(_ recordingSource: CryptoFile) { private func startPlayback(_ recordingSource: CryptoFile) {
let audioPath = getAppFilePath(recordingSource.filePath) chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
audioPlayer = AudioPlayer( audioPlayer = AudioPlayer(
onTimer: { onTimer: { playbackTime = $0 },
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
},
onFinishPlayback: { onFinishPlayback: {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
notifyStateChange()
} }
) )
audioPlayer?.start(fileSource: recordingSource, at: playbackTime) audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing playbackState = .playing
notifyStateChange()
} }
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
class VoiceItemState {
var audioPlayer: AudioPlayer?
var playbackState: VoiceMessagePlaybackState
var playbackTime: TimeInterval?
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
self.audioPlayer = audioPlayer
self.playbackState = playbackState
self.playbackTime = playbackTime
}
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = smallView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = chatView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static var smallView: [String: VoiceItemState] = [:]
static var chatView: [String: VoiceItemState] = [:]
} }
struct CIVoiceView_Previews: PreviewProvider { struct CIVoiceView_Previews: PreviewProvider {
@@ -508,12 +290,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(), chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30, duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true) allowMenu: Binding.constant(true)
) )
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
@@ -10,21 +10,22 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct DeletedItemView: View { struct DeletedItemView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@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(theme.colors.secondary) .foregroundColor(.secondary)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
} }
@@ -11,7 +11,6 @@ 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 {
@@ -19,7 +18,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, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.bottom, 8) .padding(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -12,24 +12,21 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct FramedCIVoiceView: View { struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
HStack { HStack {
VoiceMessagePlayer( VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@@ -38,15 +35,14 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: 1
) )
VoiceMessagePlayerTime( VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime playbackTime: $playbackTime
) )
.foregroundColor(theme.colors.secondary) .foregroundColor(.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()
File diff suppressed because one or more lines are too long
@@ -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,7 +71,9 @@ 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
scrollModel.scrollToItem(id: chatItem.id) if let proxy = scrollProxy {
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,7 +11,6 @@ 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
@@ -55,7 +54,6 @@ 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
@@ -64,12 +62,13 @@ struct CIMsgError: View {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.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
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool @Binding var revealed: Bool
@@ -19,10 +19,11 @@ 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(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -35,8 +36,8 @@ struct MarkedDeletedItemView: View {
var blockedByAdmin = 0 var blockedByAdmin = 0
var deleted = 0 var deleted = 0
var moderatedBy: Set<String> = [] var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count, while i < m.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]), let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory, ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted { let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted { switch itemDeleted {
@@ -26,7 +26,6 @@ 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
@@ -66,7 +65,7 @@ struct MsgContentView: View {
} }
private func msgContentView() -> Text { private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) var v = messageText(text, formattedText, sender, showSecrets: showSecrets)
if let mt = meta { if let mt = meta {
if mt.isLive { if mt.isLive {
v = v + typingIndicator(mt.recent) v = v + typingIndicator(mt.recent)
@@ -80,7 +79,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(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
@@ -88,7 +87,7 @@ struct MsgContentView: View {
} }
} }
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool) -> 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 {
@@ -103,7 +102,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
} }
if let i = icon { if let i = icon {
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
} }
if let s = sender { if let s = sender {
@@ -11,7 +11,6 @@ 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
@@ -20,8 +19,6 @@ 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?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
var body: some View { var body: some View {
NavigationView { NavigationView {
@@ -38,56 +35,81 @@ 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 {
List { ScrollView {
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary) LazyVStack(alignment: .leading, spacing: 8) {
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()
forwardListNavLinkView(chat)
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id)) .disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
} }
} }
.modifier(ThemedBackground(grouped: true)) .padding(.horizontal)
.padding(.vertical, 8)
.background(Color(uiColor: .systemBackground))
.cornerRadius(12)
.padding(.horizontal)
}
.background(Color(.systemGroupedBackground))
} else { } else {
ZStack {
emptyList() emptyList()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(ThemedBackground())
} }
} }
private func filterChatsToForwardTo() -> [Chat] {
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
return switch cInfo {
case let .direct(contact):
viewNameContains(cInfo, searchStr) ||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
contact.fullName.localizedLowercase.contains(searchStr)
default:
viewNameContains(cInfo, searchStr)
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
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
}
} }
private func emptyList() -> some View { private func emptyList() -> some View {
Text("No filtered chats") Text("No filtered chats")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { @ViewBuilder private func forwardListNavLinkView(_ chat: Chat) -> some View {
let prohibited = chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
Button { Button {
if prohibited {
alert = SomeAlert(
alert: mkAlert(
title: "Cannot forward message",
message: "Selected chat preferences prohibit this message."
),
id: "forward prohibited by preferences"
)
} else {
dismiss() dismiss()
if chat.id == fromChatInfo.id { if chat.id == fromChatInfo.id {
composeState = ComposeState( composeState = ComposeState(
@@ -97,15 +119,14 @@ struct ChatItemForwardingView: View {
) )
} else { } else {
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo) composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id) 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(prohibited ? theme.colors.secondary : theme.colors.onBackground) .foregroundColor(.primary)
.lineLimit(1) .lineLimit(1)
if chat.chatInfo.incognito { if chat.chatInfo.incognito {
Spacer() Spacer()
@@ -113,7 +134,7 @@ struct ChatItemForwardingView: View {
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 22, height: 22) .frame(width: 22, height: 22)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -126,6 +147,5 @@ struct ChatItemForwardingView: View {
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,13 +12,11 @@ import SimpleXChat
struct ChatItemInfoView: View { struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
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 {
@@ -101,14 +99,12 @@ 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)
@@ -116,7 +112,6 @@ 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 {
@@ -126,7 +121,6 @@ struct ChatItemInfoView: View {
} }
} else { } else {
historyTab() historyTab()
.modifier(ThemedBackground())
} }
} }
@@ -163,35 +157,6 @@ 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()
}
}
}
} }
} }
} }
@@ -216,7 +181,7 @@ struct ChatItemInfoView: View {
} }
else { else {
Text("No history") Text("No history")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@@ -231,8 +196,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, theme)) .background(chatItemFrameColor(ci, colorScheme))
.modifier(ChatItemClipped()) .cornerRadius(18)
.contextMenu { .contextMenu {
if itemVersion.msgContent.text != "" { if itemVersion.msgContent.text != "" {
Button { Button {
@@ -262,19 +227,18 @@ struct ChatItemInfoView: View {
} else { } else {
Text("no text") Text("no text")
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(.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, secondaryColor: theme.colors.secondary)) toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets))
} }
} }
@@ -301,8 +265,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, theme)) .background(quotedMsgFrameColor(qi, colorScheme))
.modifier(ChatItemClipped()) .cornerRadius(18)
.contextMenu { .contextMenu {
if qi.text != "" { if qi.text != "" {
Button { Button {
@@ -325,10 +289,10 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading) .frame(maxWidth: maxWidth, alignment: .leading)
} }
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color { func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
(qi.chatDir?.sent ?? false) (qi.chatDir?.sent ?? false)
? theme.appColors.sentMessage ? (colorScheme == .light ? sentColorLight : sentColorDark)
: theme.appColors.receivedMessage : Color(uiColor: .tertiarySystemGroupedBackground)
} }
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
@@ -351,7 +315,7 @@ struct ChatItemInfoView: View {
Button { Button {
Task { Task {
await MainActor.run { await MainActor.run {
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) chatModel.chatId = forwardedFromItem.chatInfo.id
dismiss() dismiss()
} }
} }
@@ -363,7 +327,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(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
@@ -377,23 +341,23 @@ struct ChatItemInfoView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("you") Text("you")
.italic() .italic()
.foregroundColor(theme.colors.onBackground) .foregroundColor(.primary)
Text(forwardedFromItem.chatInfo.chatViewName) Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary) .foregroundColor(.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(theme.colors.onBackground) .foregroundColor(.primary)
.lineLimit(1) .lineLimit(1)
Text(forwardedFromItem.chatInfo.chatViewName) Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
} else { } else {
Text(forwardedFromItem.chatInfo.chatViewName) Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.onBackground) .foregroundColor(.primary)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -415,7 +379,7 @@ struct ChatItemInfoView: View {
} }
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) { VStack(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
@@ -423,12 +387,12 @@ struct ChatItemInfoView: View {
} }
} else { } else {
Text("No delivery information") Text("No delivery information")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, 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)
@@ -438,21 +402,21 @@ struct ChatItemInfoView: View {
}) })
} }
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View { private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
HStack{ HStack{
MemberProfileImage(member, size: 30) ProfileImage(imageStr: member.image, size: 30)
.padding(.trailing, 2) .padding(.trailing, 2)
Text(member.chatViewName) Text(member.chatViewName)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
if sentViaProxy == true { if sentViaProxy == true {
Image(systemName: "arrow.forward") Image(systemName: "arrow.forward")
.foregroundColor(theme.colors.secondary).opacity(0.67) .foregroundColor(.secondary).opacity(0.67)
} }
let v = Group { let v = Group {
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) if let (icon, statusColor) = status.statusIcon(Color.secondary) {
switch status { switch status {
case .rcvd: case .sndRcvd:
ZStack(alignment: .trailing) { ZStack(alignment: .trailing) {
Image(systemName: icon) Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67)) .foregroundColor(statusColor.opacity(0.67))
@@ -464,6 +428,10 @@ struct ChatItemInfoView: View {
Image(systemName: icon) Image(systemName: icon)
.foregroundColor(statusColor) .foregroundColor(statusColor)
} }
} else {
Image(systemName: "ellipsis")
.foregroundColor(Color.secondary)
}
} }
if let (title, text) = status.statusInfo { if let (title, text) = status.statusInfo {
@@ -505,12 +473,8 @@ 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")]
+34 -50
View File
@@ -11,25 +11,35 @@ 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 playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init( init(
chat: Chat, chat: Chat,
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),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) { ) {
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
_playbackState = playbackState
_playbackTime = playbackTime
} }
var body: some View { var body: some View {
@@ -40,7 +50,7 @@ struct ChatItemView: View {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci) EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu) CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil { } else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else { } else {
@@ -52,38 +62,12 @@ struct ChatItemView: View {
} }
private func framedItemView() -> some View { private func framedItemView() -> some View {
let preview = chatItem.content.msgContent FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.flatMap {
switch $0 {
case let .image(_, image): image
case let .video(_, image, _): image
default: nil
}
}
.flatMap { UIImage(base64Encoded: $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
)
} }
} }
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
@@ -113,14 +97,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(theme.colors.secondary)) case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary)) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
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: theme.colors.secondary) CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .rcvGroupFeature(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 .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor)
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()
@@ -147,20 +131,20 @@ struct ChatItemContentView<Content: View>: View {
} }
private func eventItemView() -> some View { private func eventItemView() -> some View {
CIEventView(eventText: eventItemViewText(theme.colors.secondary)) return CIEventView(eventText: eventItemViewText())
} }
private func eventItemViewText(_ secondaryColor: Color) -> Text { private func eventItemViewText() -> Text {
if !revealed, let t = mergedGroupEventText { if !revealed, let t = mergedGroupEventText {
return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor) return chatEventText(t + Text(" ") + chatItem.timestampText)
} else if let member = chatItem.memberDisplayName { } else if let member = chatItem.memberDisplayName {
return Text(member + " ") return Text(member + " ")
.font(.caption) .font(.caption)
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
+ chatEventText(chatItem, secondaryColor) + chatEventText(chatItem)
} else { } else {
return chatEventText(chatItem, secondaryColor) return chatEventText(chatItem)
} }
} }
@@ -195,7 +179,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(theme.colors.secondary) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
: e2eeInfoNoPQText() : e2eeInfoNoPQText()
} }
@@ -203,24 +187,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(theme.colors.secondary) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
} }
} }
func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text { func chatEventText(_ text: Text) -> Text {
text text
.font(.caption) .font(.caption)
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
} }
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text { func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor) chatEventText(Text(eventText) + Text(" ") + ts)
} }
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { func chatEventText(_ ci: ChatItem) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor) chatEventText("\(ci.content.text)", ci.timestampText)
} }
struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_Previews: PreviewProvider {
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@
import SwiftUI import SwiftUI
struct ComposeFileView: View { struct ComposeFileView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let fileName: String let fileName: String
let cancelFile: (() -> Void) let cancelFile: (() -> Void)
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -32,8 +32,9 @@ struct ComposeFileView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.frame(height: 54) .frame(height: 50)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
} }
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ComposeImageView: View { struct ComposeImageView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let images: [String] let images: [String]
let cancelImage: (() -> Void) let cancelImage: (() -> Void)
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -18,7 +18,10 @@ struct ComposeImageView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in let imgs: [UIImage] = images.compactMap { image in
UIImage(base64Encoded: image) if let data = Data(base64Encoded: dropImagePrefix(image)) {
return UIImage(data: data)
}
return nil
} }
if imgs.count == 0 { if imgs.count == 0 {
ProgressView() ProgressView()
@@ -45,9 +48,9 @@ struct ComposeImageView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
} }
@@ -10,8 +10,37 @@ import SwiftUI
import LinkPresentation import LinkPresentation
import SimpleXChat import SimpleXChat
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
cb(nil)
}
}
}
struct ComposeLinkView: View { struct ComposeLinkView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let linkPreview: LinkPreview? let linkPreview: LinkPreview?
var cancelPreview: (() -> Void)? = nil var cancelPreview: (() -> Void)? = nil
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -33,14 +62,15 @@ struct ComposeLinkView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View { private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) { if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -52,10 +82,10 @@ struct ComposeLinkView: View {
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.vertical, 5) .padding(.vertical, 5)
.frame(maxWidth: .infinity, minHeight: 60) .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
} }
} }
} }
@@ -254,7 +254,6 @@ 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
@@ -281,28 +280,21 @@ struct ComposeView: View {
@State private var stopPlayback: Bool = false @State private var stopPlayback: Bool = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Divider()
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
ContextInvitingContactMemberView() ContextInvitingContactMemberView()
Divider()
} }
// 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)
if simplexLinkProhibited { if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link") msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
} else if fileProhibited { } else if fileProhibited {
msgNotAllowedView("Files and media not allowed", icon: "doc") msgNotAllowedView("Files and media not allowed", icon: "doc")
Divider()
} else if voiceProhibited { } else if voiceProhibited {
msgNotAllowedView("Voice messages not allowed", icon: "mic") msgNotAllowedView("Voice messages not allowed", icon: "mic")
Divider()
} }
contextItemView() contextItemView()
switch (composeState.editing, composeState.preview) { switch (composeState.editing, composeState.preview) {
@@ -321,7 +313,6 @@ 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 {
@@ -362,15 +353,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)
: theme.colors.primary : .accentColor
) )
.padding(.trailing, 12) .padding(.trailing, 12)
.background(.background)
.disabled(!chat.userCanSend) .disabled(!chat.userCanSend)
if chat.userIsObserver { if chat.userIsObserver {
Text("you are observer") Text("you are observer")
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -382,11 +374,6 @@ struct ComposeView: View {
} }
} }
} }
.background {
Color.clear
.overlay(ToolbarMaterial.material(toolbarMaterial))
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in .onChange(of: composeState.message) { msg in
if composeState.linkPreviewAllowed { if composeState.linkPreviewAllowed {
if msg.count > 0 { if msg.count > 0 {
@@ -635,7 +622,6 @@ struct ComposeView: View {
cancelPreview: cancelLinkPreview, cancelPreview: cancelLinkPreview,
cancelEnabled: !composeState.inProgress cancelEnabled: !composeState.inProgress
) )
Divider()
case let .mediaPreviews(mediaPreviews: media): case let .mediaPreviews(mediaPreviews: media):
ComposeImageView( ComposeImageView(
images: media.map { (img, _) in img }, images: media.map { (img, _) in img },
@@ -644,7 +630,6 @@ struct ComposeView: View {
chosenMedia = [] chosenMedia = []
}, },
cancelEnabled: !composeState.editing && !composeState.inProgress) cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
case let .voicePreview(recordingFileName, _): case let .voicePreview(recordingFileName, _):
ComposeVoiceView( ComposeVoiceView(
recordingFileName: recordingFileName, recordingFileName: recordingFileName,
@@ -657,7 +642,6 @@ struct ComposeView: View {
cancelEnabled: !composeState.editing && !composeState.inProgress, cancelEnabled: !composeState.editing && !composeState.inProgress,
stopPlayback: $stopPlayback stopPlayback: $stopPlayback
) )
Divider()
case let .filePreview(fileName, _): case let .filePreview(fileName, _):
ComposeFileView( ComposeFileView(
fileName: fileName, fileName: fileName,
@@ -665,19 +649,19 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview) composeState = composeState.copy(preview: .noPreview)
}, },
cancelEnabled: !composeState.editing && !composeState.inProgress) cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
} }
} }
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View { private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
HStack { HStack {
Image(systemName: icon).foregroundColor(theme.colors.secondary) Image(systemName: icon).foregroundColor(.secondary)
Text(reason).italic() Text(reason).italic()
} }
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.padding(.top, 8)
} }
@ViewBuilder private func contextItemView() -> some View { @ViewBuilder private func contextItemView() -> some View {
@@ -691,7 +675,6 @@ struct ComposeView: View {
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
Divider()
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
@@ -699,7 +682,6 @@ struct ComposeView: View {
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
) )
Divider()
case let .forwardingItem(chatItem: forwardedItem, _): case let .forwardingItem(chatItem: forwardedItem, _):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
@@ -708,7 +690,6 @@ struct ComposeView: View {
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
showSender: false showSender: false
) )
Divider()
} }
} }
@@ -865,7 +846,6 @@ struct ComposeView: View {
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) { if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
ChatModel.shared.filesToDelete.remove(url)
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl) return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
return nil return nil
@@ -1085,7 +1065,7 @@ struct ComposeView: View {
} else { } else {
nil nil
} }
let simplexLink = parsedMsgHasSimplexLink(parsedMsg) let simplexLink = parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
return (url, simplexLink) return (url, simplexLink)
} }
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
struct ComposeVoiceView: View { struct ComposeVoiceView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var recordingFileName: String var recordingFileName: String
@Binding var recordingTime: TimeInterval? @Binding var recordingTime: TimeInterval?
@Binding var recordingState: VoiceMessageRecordingState @Binding var recordingState: VoiceMessageRecordingState
@@ -50,9 +50,9 @@ struct ComposeVoiceView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight) .frame(height: ComposeVoiceView.previewHeight)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
private func recordingMode() -> some View { private func recordingMode() -> some View {
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
Button { Button {
startPlayback() startPlayback()
} label: { } label: {
playPauseIcon("play.fill", theme.colors.primary) playPauseIcon("play.fill")
} }
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", theme.colors.primary) playPauseIcon("pause.fill")
} }
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", theme.colors.primary) playPauseIcon("play.fill")
} }
Text(voiceMessageTime_(playbackTime)) Text(voiceMessageTime_(playbackTime))
} }
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
} }
} }
private func playPauseIcon(_ image: String, _ color: Color) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -147,11 +147,9 @@ 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
@@ -160,12 +158,10 @@ 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?
@@ -173,7 +169,7 @@ struct ComposeVoiceView: View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
Rectangle() Rectangle()
.fill(theme.colors.primary) .fill(Color.accentColor)
.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,19 @@
import SwiftUI import SwiftUI
struct ContextInvitingContactMemberView: View { struct ContextInvitingContactMemberView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
HStack { HStack {
Image(systemName: "message") Image(systemName: "message")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
Text("Send direct message to connect") Text("Send direct message to connect")
} }
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.padding(.top, 8)
} }
} }
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ContextItemView: View { struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@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(theme.colors.secondary) .foregroundColor(.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(theme.colors.secondary) Text(sender).font(.caption).foregroundColor(.secondary)
msgContentView(lines: 2) msgContentView(lines: 2)
} }
} else { } else {
@@ -40,12 +40,12 @@ struct ContextItemView: View {
} label: { } label: {
Image(systemName: "multiply") Image(systemName: "multiply")
} }
.tint(theme.colors.primary)
} }
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 50)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, theme)) .background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8)
} }
private func msgContentView(lines: Int) -> some View { private func msgContentView(lines: Int) -> some View {
@@ -55,7 +55,7 @@ struct ContextItemView: View {
} }
private func contextMsgPreview() -> Text { private func contextMsgPreview() -> Text {
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false)
func attachment() -> Text { func attachment() -> Text {
switch contextItem.content.msgContent { switch contextItem.content.msgContent {
@@ -28,7 +28,6 @@ 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,7 +13,6 @@ 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
@@ -44,14 +43,13 @@ struct SendMessageView: View {
var body: some View { var body: some View {
ZStack { ZStack {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
if case .voicePreview = composeState.preview { if case .voicePreview = composeState.preview {
Text("Voice message…") Text("Voice message…")
.font(teFont.italic()) .font(teFont.italic())
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 8)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -67,6 +65,7 @@ struct SendMessageView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
if progressByTimeout { if progressByTimeout {
ProgressView() ProgressView()
.scaleEffect(1.4) .scaleEffect(1.4)
@@ -84,9 +83,10 @@ struct SendMessageView: View {
} }
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.background(theme.colors.background) .overlay(
.clipShape(composeShape) RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
} }
.onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in .onChange(of: composeState.inProgress) { inProgress in
@@ -247,7 +247,6 @@ 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
@@ -257,10 +256,7 @@ struct SendMessageView: View {
var body: some View { var body: some View {
Button(action: {}) { Button(action: {}) {
Image(systemName: "mic.fill") Image(systemName: "mic.fill")
.resizable() .foregroundColor(.accentColor)
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary)
} }
.disabled(disabled) .disabled(disabled)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -313,10 +309,7 @@ struct SendMessageView: View {
} }
} label: { } label: {
Image(systemName: "mic") Image(systemName: "mic")
.resizable() .foregroundColor(.secondary)
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.secondary)
} }
.disabled(composeState.inProgress) .disabled(composeState.inProgress)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -330,7 +323,7 @@ struct SendMessageView: View {
Image(systemName: "multiply") Image(systemName: "multiply")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.frame(width: 15, height: 15) .frame(width: 15, height: 15)
} }
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -347,7 +340,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
} }
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -390,7 +383,7 @@ struct SendMessageView: View {
} }
Task { Task {
_ = try? await Task.sleep(nanoseconds: liveMsgInterval) _ = try? await Task.sleep(nanoseconds: liveMsgInterval)
while await composeState.liveMessage != nil { while composeState.liveMessage != nil {
await update() await update()
_ = try? await Task.sleep(nanoseconds: liveMsgInterval) _ = try? await Task.sleep(nanoseconds: liveMsgInterval)
} }
@@ -401,7 +394,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(theme.colors.primary) .foregroundColor(.accentColor)
} }
.disabled(composeState.inProgress) .disabled(composeState.inProgress)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -12,7 +12,6 @@ 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
@@ -67,8 +66,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).foregroundColor(theme.colors.secondary) } header: { featureHeader(feature, enabled) }
footer: { featureFooter(feature, enabled).foregroundColor(theme.colors.secondary) } footer: { featureFooter(feature, enabled) }
} }
private func timedMessagesFeatureSection() -> some View { private func timedMessagesFeatureSection() -> some View {
@@ -103,8 +102,8 @@ struct ContactPreferencesView: View {
infoRow("Delete after", timeText(pref.contactPreference.ttl)) infoRow("Delete after", timeText(pref.contactPreference.ttl))
} }
} }
header: { featureHeader(.timedMessages, enabled).foregroundColor(theme.colors.secondary) } header: { featureHeader(.timedMessages, enabled) }
footer: { featureFooter(.timedMessages, enabled).foregroundColor(theme.colors.secondary) } footer: { featureFooter(.timedMessages, enabled) }
} }
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View { private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
@@ -21,7 +21,6 @@ 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
@@ -35,7 +34,7 @@ struct AddGroupMembersViewCommon: View {
private enum AddGroupMembersAlert: Identifiable { private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito case prohibitedToInviteIncognito
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String { var id: String {
switch self { switch self {
@@ -47,13 +46,14 @@ struct AddGroupMembersViewCommon: View {
var body: some View { var body: some View {
if creatingGroup { if creatingGroup {
NavigationView {
addGroupMembersView() addGroupMembersView()
.navigationBarBackButtonHidden()
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb(selectedContacts) } Button ("Skip") { addedMembersCb(selectedContacts) }
} }
} }
}
} else { } else {
addGroupMembersView() addGroupMembersView()
} }
@@ -70,7 +70,7 @@ struct AddGroupMembersViewCommon: View {
if (membersToAdd.isEmpty) { if (membersToAdd.isEmpty) {
Text("No contacts to add") Text("No contacts to add")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -90,18 +90,16 @@ 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, theme.colors.primary, theme.colors.secondary) searchFieldView(text: $searchText, focussed: $searchFocussed)
.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) }
@@ -121,13 +119,12 @@ struct AddGroupMembersViewCommon: View {
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile") message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
) )
case let .error(title, error): case let .error(title, error):
return mkAlert(title: title, message: error) return Alert(title: Text(title), message: Text(error))
} }
} }
.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 {
@@ -175,14 +172,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).asAnotherColorFromSecondary(theme) iconColor = Color(uiColor: .tertiaryLabel)
} else { } else {
if checked { if checked {
icon = "checkmark.circle.fill" icon = "checkmark.circle.fill"
iconColor = theme.colors.primary iconColor = .accentColor
} else { } else {
icon = "circle" icon = "circle"
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) iconColor = Color(uiColor: .tertiaryLabel)
} }
} }
return Button { return Button {
@@ -200,7 +197,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 ? theme.colors.secondary : theme.colors.onBackground) .foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
Image(systemName: icon) Image(systemName: icon)
@@ -210,7 +207,7 @@ struct AddGroupMembersViewCommon: View {
} }
} }
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding, _ onBackgroundColor: Color, _ secondaryColor: Color) -> some View { func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding) -> some View {
HStack { HStack {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.resizable() .resizable()
@@ -219,7 +216,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(onBackgroundColor) .foregroundColor(.primary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.resizable() .resizable()
@@ -231,7 +228,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding,
focussed.wrappedValue = false focussed.wrappedValue = false
} }
} }
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.frame(height: 36) .frame(height: 36)
} }
@@ -13,16 +13,13 @@ 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
var onSearch: () -> Void
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@State private var connectionCode: String? @State private var connectionCode: String?
@State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceipts = SendReceipts.userDefault(true)
@@ -42,7 +39,7 @@ struct GroupChatInfoView: View {
case blockForAllAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@@ -70,15 +67,6 @@ struct GroupChatInfoView: View {
List { List {
groupInfoHeader() groupInfoHeader()
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.padding(.bottom, 18)
infoActionButtons()
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section { Section {
if groupInfo.canEdit { if groupInfo.canEdit {
@@ -93,19 +81,13 @@ 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(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { Section("\(members.count + 1) members") {
if groupInfo.canAddMembers { if groupInfo.canAddMembers {
groupLinkButton() groupLinkButton()
if (chat.chatInfo.incognito) { if (chat.chatInfo.incognito) {
@@ -117,7 +99,7 @@ struct GroupChatInfoView: View {
} }
} }
if members.count > 8 { if members.count > 8 {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) searchFieldView(text: $searchText, focussed: $searchFocussed)
.padding(.leading, 8) .padding(.leading, 8)
} }
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
@@ -147,13 +129,12 @@ struct GroupChatInfoView: View {
} }
if developerTools { if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { Section(header: Text("For console")) {
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)
@@ -169,7 +150,7 @@ struct GroupChatInfoView: View {
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem) case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
.onAppear { .onAppear {
@@ -185,6 +166,7 @@ struct GroupChatInfoView: View {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
} }
} }
.keyboardPadding()
} }
private func groupInfoHeader() -> some View { private func groupInfoHeader() -> some View {
@@ -208,82 +190,8 @@ struct GroupChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
searchButton(width: buttonWidth)
if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
muteButton(width: buttonWidth)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
}
@ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
if chat.chatInfo.incognito {
ZStack {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true
}
NavigationLink(isActive: $groupLinkNavLinkActive) {
groupLinkDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
} else {
ZStack {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true
}
NavigationLink(isActive: $addMembersNavLinkActive) {
addMembersDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
}
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
}
.disabled(!groupInfo.ready)
}
private func addMembersButton() -> some View { private func addMembersButton() -> some View {
NavigationLink { NavigationLink {
addMembersDestinationView()
} label: {
Label("Invite members", systemImage: "plus")
}
}
private func addMembersDestinationView() -> some View {
AddGroupMembersView(chat: chat, groupInfo: groupInfo) AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear { .onAppear {
searchFocussed = false searchFocussed = false
@@ -291,33 +199,35 @@ 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()
} }
} }
} }
} label: {
Label("Invite members", systemImage: "plus")
}
} }
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?
var body: some View { var body: some View {
let member = groupMember.wrapped let member = groupMember.wrapped
let v = HStack{ let v = HStack{
MemberProfileImage(member, size: 38) ProfileImage(imageStr: member.image, size: 38)
.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 : theme.colors.onBackground) let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
(member.verified ? memberVerifiedShield + t : t) (member.verified ? memberVerifiedShield + t : t)
.lineLimit(1) .lineLimit(1)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member))) let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1) .lineLimit(1)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
memberInfo(member) memberInfo(member)
@@ -347,25 +257,15 @@ 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(theme.colors.secondary) .foregroundColor(.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(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
@@ -376,13 +276,13 @@ struct GroupChatInfoView: View {
Button { Button {
alert = .blockMemberAlert(mem: member) alert = .blockMemberAlert(mem: member)
} label: { } label: {
Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary) Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
} }
} else { } else {
Button { Button {
alert = .unblockMemberAlert(mem: member) alert = .unblockMemberAlert(mem: member)
} label: { } label: {
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary) Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
} }
} }
} }
@@ -394,13 +294,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(theme.colors.primary) Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
} }
} else { } else {
Button { Button {
alert = .blockForAllAlert(mem: member) alert = .blockForAllAlert(mem: member)
} label: { } label: {
Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary) Label("Block for all", systemImage: "hand.raised").foregroundColor(.secondary)
} }
} }
} }
@@ -416,14 +316,6 @@ 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 {
@@ -433,7 +325,15 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
groupLinkDestinationView() GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
if groupLink == nil { if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus") Label("Create group link", systemImage: "link.badge.plus")
@@ -443,19 +343,6 @@ struct GroupChatInfoView: View {
} }
} }
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
}
private func editGroupButton() -> some View { private func editGroupButton() -> some View {
NavigationLink { NavigationLink {
GroupProfileView( GroupProfileView(
@@ -463,7 +350,6 @@ 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")
@@ -478,7 +364,6 @@ 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
@@ -633,7 +518,6 @@ 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 {
@@ -644,6 +528,14 @@ 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!"),
@@ -662,8 +554,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupChatInfoView( GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData), groupInfo: Binding.constant(GroupInfo.sampleData)
onSearch: {}
) )
} }
} }
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
private enum GroupLinkAlert: Identifiable { private enum GroupLinkAlert: Identifiable {
case deleteLink case deleteLink
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String { var id: String {
switch self { switch self {
@@ -34,13 +34,14 @@ struct GroupLinkView: View {
var body: some View { var body: some View {
if creatingGroup { if creatingGroup {
NavigationView {
groupLinkView() groupLinkView()
.navigationBarBackButtonHidden()
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() } Button ("Continue") { linkCreatedCb?() }
} }
} }
}
} else { } else {
groupLinkView() groupLinkView()
} }
@@ -112,7 +113,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel() }, secondaryButton: .cancel()
) )
case let .error(title, error): case let .error(title, error):
return mkAlert(title: title, message: error) return Alert(title: Text(title), message: Text(error))
} }
} }
.onChange(of: groupLinkMemberRole) { _ in .onChange(of: groupLinkMemberRole) { _ in
@@ -132,7 +133,6 @@ struct GroupLinkView: View {
shouldCreate = false shouldCreate = false
} }
} }
.modifier(ThemedBackground(grouped: true))
} }
private func createGroupLink() { private func createGroupLink() {
@@ -11,7 +11,6 @@ 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
@@ -37,8 +36,7 @@ struct GroupMemberInfoView: View {
case syncConnectionForceAlert case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert) case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String) case queueInfo(info: String)
case someAlert(alert: SomeAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String { var id: String {
switch self { switch self {
@@ -53,7 +51,6 @@ struct GroupMemberInfoView: View {
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 .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "someAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
} }
} }
@@ -67,11 +64,10 @@ struct GroupMemberInfoView: View {
} }
} }
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? { private func knownDirectChat(_ contactId: Int64) -> Chat? {
if let chat = chatModel.getContactChat(contactId), if let chat = chatModel.getContactChat(contactId),
let contact = chat.chatInfo.contact, chat.chatInfo.contact?.directOrUsed == true {
contact.directOrUsed == true { return chat
return (chat, contact)
} else { } else {
return nil return nil
} }
@@ -79,23 +75,23 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View { private func groupMemberInfoView() -> some View {
ZStack { ZStack {
VStack {
let member = groupMember.wrapped let member = groupMember.wrapped
List { List {
groupMemberInfoHeader(member) groupMemberInfoHeader(member)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
infoActionButtons(member)
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
if member.memberActive { if member.memberActive {
Section { Section {
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
}
if let code = connectionCode { verifyCodeButton(code) } if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats, if let connStats = connectionStats,
connStats.ratchetSyncAllowed { connStats.ratchetSyncAllowed {
@@ -124,14 +120,12 @@ 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(header: Text("Member").foregroundColor(theme.colors.secondary)) { Section("Member") {
infoRow("Group", groupInfo.displayName) infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) { if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
@@ -144,10 +138,16 @@ 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(header: Text("Servers").foregroundColor(theme.colors.secondary)) { Section("Servers") {
// TODO network connection status // TODO network connection status
Button("Change receiving address") { Button("Change receiving address") {
alert = .switchAddressAlert alert = .switchAddressAlert
@@ -165,8 +165,8 @@ struct GroupMemberInfoView: View {
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
} }
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
} }
} }
@@ -177,13 +177,9 @@ struct GroupMemberInfoView: View {
} }
if developerTools { if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { Section("For console") {
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") { Button ("Debug delivery") {
Task { Task {
do { do {
@@ -227,6 +223,7 @@ struct GroupMemberInfoView: View {
.onChange(of: member.memberRole) { role in .onChange(of: member.memberRole) { role in
newRole = role newRole = role
} }
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
switch(alertItem) { switch(alertItem) {
@@ -241,8 +238,7 @@ struct GroupMemberInfoView: View {
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 .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .error(title, error): return mkAlert(title: title, message: error)
} }
} }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
@@ -251,58 +247,6 @@ struct GroupMemberInfoView: View {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
} }
} }
.modifier(ThemedBackground(grouped: true))
}
func infoActionButtons(_ member: GroupMember) -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton(width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
} else { // no known contact chat && directMessages are off
InfoViewButton(image: "message.fill", title: "message", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't message member")
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
func showSendMessageToEnableCallsAlert() {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't call member",
message: "Send message to enable calls."
),
id: "can't call member, send message"
))
}
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: title,
message: "Direct messages between members are prohibited in this group."
),
id: "can't message member, direct messages prohibited"
))
} }
func connectViaAddressButton(_ contactLink: String) -> some View { func connectViaAddressButton(_ contactLink: String) -> some View {
@@ -319,32 +263,36 @@ struct GroupMemberInfoView: View {
} }
} }
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View { func knownDirectChatButton(_ chat: Chat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { Button {
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
} }
} label: {
Label("Send direct message", systemImage: "message")
} }
} }
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View { func newDirectChatButton(_ contactId: Int64) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { Button {
Task {
do { do {
let chat = try await apiGetChat(type: .direct, id: contactId) let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat) chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
} }
} catch let error { } catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
} }
} } label: {
Label("Send direct message", systemImage: "message")
} }
} }
func createMemberContactButton(width: CGFloat) -> some View { func createMemberContactButton() -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { Button {
progressIndicator = true progressIndicator = true
Task { Task {
do { do {
@@ -352,10 +300,9 @@ struct GroupMemberInfoView: View {
await MainActor.run { await MainActor.run {
progressIndicator = false progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
} chatModel.chatId = memberContact.id
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) chatModel.setContactNetworkStatus(memberContact, .connected)
} }
} catch let error { } catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
@@ -366,18 +313,20 @@ struct GroupMemberInfoView: View {
} }
} }
} }
} label: {
Label("Send direct message", systemImage: "message")
} }
} }
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View { private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack { VStack {
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill)) ProfileImage(imageStr: mem.image, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12) .padding(.top, 12)
.padding() .padding()
if mem.verified { if mem.verified {
( (
Text(Image(systemName: "checkmark.shield")) Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.font(.title2) .font(.title2)
+ Text(" ") + Text(" ")
+ Text(mem.displayName) + Text(mem.displayName)
@@ -425,7 +374,6 @@ 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",
@@ -475,7 +423,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(theme.colors.secondary) .foregroundColor(.secondary)
} else if mem.memberSettings.showMessages { } else if mem.memberSettings.showMessages {
blockMemberButton(mem) blockMemberButton(mem)
} else { } else {
@@ -633,21 +581,6 @@ struct GroupMemberInfoView: View {
} }
} }
func MemberProfileImage(
_ mem: GroupMember,
size: CGFloat,
color: Color = Color(uiColor: .tertiarySystemGroupedBackground),
backgroundColor: Color? = nil
) -> some View {
ProfileImage(
imageStr: mem.image,
size: size,
color: color,
backgroundColor: backgroundColor,
blurred: mem.blocked
)
}
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert( Alert(
title: Text("Block member?"), title: Text("Block member?"),
@@ -18,7 +18,6 @@ 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
@@ -74,7 +73,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 : theme.colors.secondary let color: Color = enableFeature.wrappedValue == .on ? .green : .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 {
@@ -112,19 +111,18 @@ struct GroupPreferencesView: View {
} }
if enableFeature.wrappedValue == .on, let enableForRole { if enableFeature.wrappedValue == .on, let enableForRole {
HStack { HStack {
Text("Enabled for").foregroundColor(theme.colors.secondary) Text("Enabled for").foregroundColor(.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(theme.colors.secondary) .foregroundColor(.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,7 +11,6 @@ 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
@@ -58,7 +57,7 @@ struct GroupWelcomeView: View {
} }
private func textPreview() -> some View { private func textPreview() -> some View {
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary) messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
.frame(minHeight: 130, alignment: .topLeading) .frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -71,7 +70,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(theme.colors.secondary) .foregroundColor(.secondary)
.disabled(true) .disabled(true)
} }
TextEditor(text: $welcomeText) TextEditor(text: $welcomeText)
@@ -1,309 +0,0 @@
//
// 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: 0, 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 = 0
private var bag = Set<AnyCancellable>()
init(representer: ReverseList) {
self.representer = representer
super.init(style: .plain)
// 1. Style
tableView = InvertedTableView()
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, 0)
.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
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
tableView.clipsToBounds = false
parent?.viewIfLoaded?.clipsToBounds = false
}
/// 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) {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
animated: animated
)
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
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 != 0 && 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()
}
}
class InvertedTableView: UITableView {
static let inset = CGFloat(100)
static let insets = UIEdgeInsets(
top: inset,
left: .zero,
bottom: inset,
right: .zero
)
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
get { .never }
set { }
}
override var contentInset: UIEdgeInsets {
get { Self.insets }
set { }
}
override var adjustedContentInset: UIEdgeInsets {
Self.insets
}
}
@@ -1,133 +0,0 @@
//
// SelectableChatItemToolbars.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 30.07.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SelectedItemsTopToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@Binding var selectedChatItems: Set<Int64>?
var body: some View {
let count = selectedChatItems?.count ?? 0
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
}
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var allButtonsDisabled = false
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center) {
Button {
deleteItems(deleteForEveryoneEnabled)
} label: {
Image(systemName: "trash")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
}
.disabled(!deleteEnabled || allButtonsDisabled)
Spacer()
Button {
moderateItems()
} label: {
Image(systemName: "flag")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
//shareItems()
} label: {
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
}
.disabled(allButtonsDisabled)
.opacity(0)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
}
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0
allButtonsDisabled = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems {
let me: Bool
let onlyOwnGroupItems: Bool
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, me, onlyOwnGroupItems, sel)
} else {
return r
}
}
moderateEnabled = me && !onlyOwnGroupItems
}
}
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
groupInfo.membership.memberRole >= .admin
default: false
}
}
}
@@ -10,7 +10,6 @@ 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
@@ -31,7 +30,7 @@ struct VerifyCodeView: View {
HStack { HStack {
if connectionVerified { if connectionVerified {
Image(systemName: "checkmark.shield") Image(systemName: "checkmark.shield")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
Text("\(displayName) is verified") Text("\(displayName) is verified")
} else { } else {
Text("\(displayName) is not verified") Text("\(displayName) is not verified")
@@ -67,7 +66,6 @@ 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")
} }
@@ -124,6 +122,5 @@ 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())
} }
} }
@@ -11,6 +11,7 @@ import SwiftUI
struct ChatHelp: View { struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool @Binding var showSettings: Bool
@State private var newChatMenuOption: NewChatMenuOption? = nil
var body: some View { var body: some View {
ScrollView { chatHelp() } ScrollView { chatHelp() }
@@ -38,7 +39,7 @@ struct ChatHelp: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Text("Tap button ") Text("Tap button ")
NewChatMenuButton() NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
Text("above, then choose:") Text("above, then choose:")
} }
@@ -9,56 +9,34 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
typealias DynamicSizes = ( private let rowHeights: [DynamicTypeSize: CGFloat] = [
rowHeight: CGFloat, .xSmall: 68,
profileImageSize: CGFloat, .small: 72,
mediaSize: CGFloat, .medium: 76,
incognitoSize: CGFloat, .large: 80,
chatInfoSize: CGFloat, .xLarge: 88,
unreadCorner: CGFloat, .xxLarge: 94,
unreadPadding: CGFloat .xxxLarge: 104,
) .accessibility1: 90,
.accessibility2: 100,
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [ .accessibility3: 120,
.xSmall: (68, 55, 33, 22, 18, 9, 3), .accessibility4: 130,
.small: (72, 57, 34, 22, 18, 9, 3), .accessibility5: 140
.medium: (76, 60, 36, 22, 18, 10, 4),
.large: (80, 63, 38, 24, 20, 10, 4),
.xLarge: (88, 67, 41, 24, 20, 10, 4),
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
] ]
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
dynamicSizes[font] ?? defaultDynamicSizes
}
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 userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@State private var showContactRequestDialog = false @State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false @State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false @State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false @State private var showInvalidJSON = false
@State private var alert: SomeAlert? = nil @State private var showDeleteContactActionSheet = false
@State private var actionSheet: SomeActionSheet? = nil
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false @State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false @State private var inProgress = false
@State private var progressByTimeout = false @State private var progressByTimeout = false
var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
var body: some View { var body: some View {
Group { Group {
switch chat.chatInfo { switch chat.chatInfo {
@@ -89,21 +67,14 @@ struct ChatListNavLink: View {
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group { Group {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { if contact.activeConn == nil && contact.profile.contactLink != nil {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
deleteContactDialog( showDeleteContactActionSheet = true
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
@@ -114,44 +85,51 @@ struct ChatListNavLink: View {
} }
} else { } else {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
) )
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
toggleNtfsButton(chat: chat) ToggleNtfsButton(chat: chat)
} }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty { if !chat.chatItems.isEmpty {
clearChatButton() clearChatButton()
} }
Button { Button {
deleteContactDialog( if contact.ready || !contact.active {
chat, showDeleteContactActionSheet = true
contact, } else {
dismissToChatList: false, AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
showAlert: { alert = $0 }, }
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
} }
} }
.alert(item: $alert) { $0.alert } .actionSheet(isPresented: $showDeleteContactActionSheet) {
.actionSheet(item: $actionSheet) { $0.actionSheet } if contact.ready && contact.active {
.sheet(item: $sheet) { return ActionSheet(
if #available(iOS 16.0, *) { title: Text("Delete contact?\nThis cannot be undone!"),
$0.content buttons: [
.presentationDetents([.fraction(0.4)]) .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
.cancel()
]
)
} else { } else {
$0.content return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
} }
} }
} }
@@ -160,7 +138,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton() joinGroupButton()
if groupInfo.canDelete { if groupInfo.canDelete {
@@ -180,7 +158,7 @@ struct ChatListNavLink: View {
.disabled(inProgress) .disabled(inProgress)
case .memAccepted: case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
} }
@@ -194,16 +172,16 @@ struct ChatListNavLink: View {
} }
default: default:
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready disabled: !groupInfo.ready
) )
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
toggleNtfsButton(chat: chat) ToggleNtfsButton(chat: chat)
} }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty { if !chat.chatItems.isEmpty {
@@ -221,12 +199,12 @@ struct ChatListNavLink: View {
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready disabled: !noteFolder.ready
) )
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
} }
@@ -244,9 +222,9 @@ struct ChatListNavLink: View {
await MainActor.run { inProgress = false } await MainActor.run { inProgress = false }
} }
} label: { } label: {
SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI) Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
} }
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary) .tint(chat.chatInfo.incognito ? .indigo : .accentColor)
} }
@ViewBuilder private func markReadButton() -> some View { @ViewBuilder private func markReadButton() -> some View {
@@ -254,16 +232,16 @@ struct ChatListNavLink: View {
Button { Button {
Task { await markChatRead(chat) } Task { await markChatRead(chat) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) Label("Read", systemImage: "checkmark")
} }
.tint(theme.colors.primary) .tint(Color.accentColor)
} else { } else {
Button { Button {
Task { await markChatUnread(chat) } Task { await markChatUnread(chat) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI) Label("Unread", systemImage: "circlebadge.fill")
} }
.tint(theme.colors.primary) .tint(Color.accentColor)
} }
} }
@@ -273,36 +251,24 @@ struct ChatListNavLink: View {
Button { Button {
toggleChatFavorite(chat, favorite: false) toggleChatFavorite(chat, favorite: false)
} label: { } label: {
SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI) Label("Unfav.", systemImage: "star.slash")
} }
.tint(.green) .tint(.green)
} else { } else {
Button { Button {
toggleChatFavorite(chat, favorite: true) toggleChatFavorite(chat, favorite: true)
} label: { } label: {
SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI) Label("Favorite", systemImage: "star.fill")
} }
.tint(.green) .tint(.green)
} }
} }
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
Button {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} label: {
if chat.chatInfo.ntfsEnabled {
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
} else {
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
}
}
}
private func clearChatButton() -> some View { private func clearChatButton() -> some View {
Button { Button {
AlertManager.shared.showAlert(clearChatAlert()) AlertManager.shared.showAlert(clearChatAlert())
} label: { } label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI) Label("Clear", systemImage: "gobackward")
} }
.tint(Color.orange) .tint(Color.orange)
} }
@@ -311,7 +277,7 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(clearNoteFolderAlert()) AlertManager.shared.showAlert(clearNoteFolderAlert())
} label: { } label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI) Label("Clear", systemImage: "gobackward")
} }
.tint(Color.orange) .tint(Color.orange)
} }
@@ -320,7 +286,7 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
} label: { } label: {
SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI) Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
} }
.tint(Color.yellow) .tint(Color.yellow)
} }
@@ -329,7 +295,7 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
@@ -339,23 +305,22 @@ struct ChatListNavLink: View {
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } } label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary) .tint(.accentColor)
Button { Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) Label("Accept incognito", systemImage: "theatermasks")
} }
.tint(.indigo) .tint(.indigo)
Button { Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: { } label: {
SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI) Label("Reject", systemImage: "multiply")
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true } .onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } } Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@@ -372,37 +337,31 @@ struct ChatListNavLink: View {
AlertManager.shared.showAlertMsg(title: a.title, message: a.message) AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
}) })
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
Button { Button {
showContactConnectionInfo = true showContactConnectionInfo = true
} label: { } label: {
SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI) Label("Name", systemImage: "pencil")
} }
.tint(theme.colors.primary) .tint(.accentColor)
} }
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.appSheet(isPresented: $showContactConnectionInfo) { .appSheet(isPresented: $showContactConnectionInfo) {
Group { Group {
if case let .contactConnection(contactConnection) = chat.chatInfo { if case let .contactConnection(contactConnection) = chat.chatInfo {
ContactConnectionInfo(contactConnection: contactConnection) ContactConnectionInfo(contactConnection: contactConnection)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.modifier(ThemedBackground(grouped: true))
} }
} }
} }
.contentShape(Rectangle())
.onTapGesture { .onTapGesture {
showContactConnectionInfo = true showContactConnectionInfo = true
} }
} }
private var deleteLabel: some View {
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert( Alert(
title: Text("Delete group?"), title: Text("Delete group?"),
@@ -451,6 +410,28 @@ struct ChatListNavLink: View {
) )
} }
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
}
private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Contact is not connected yet!"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .cancel(),
secondaryButton: .destructive(Text("Delete Contact")) {
removePendingContact(chat, contact)
}
)
}
private func groupInvitationAcceptedAlert() -> Alert { private func groupInvitationAcceptedAlert() -> Alert {
Alert( Alert(
title: Text("Joining group"), title: Text("Joining group"),
@@ -458,11 +439,35 @@ struct ChatListNavLink: View {
) )
} }
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .destructive(Text("Delete")) {
removePendingContact(chat, contact)
},
secondaryButton: .cancel()
)
}
private func removePendingContact(_ chat: Chat, _ contact: Contact) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
DispatchQueue.main.async {
chatModel.removeChat(contact.id)
}
} catch let error {
logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))")
}
}
}
private func invalidJSONPreview(_ json: String) -> some View { private func invalidJSONPreview(_ json: String) -> some View {
Text("invalid chat data") Text("invalid chat data")
.foregroundColor(.red) .foregroundColor(.red)
.padding(4) .padding(4)
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showInvalidJSON = true } .onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) { .appSheet(isPresented: $showInvalidJSON) {
invalidJSONView(json) invalidJSONView(json)
@@ -472,24 +477,14 @@ struct ChatListNavLink: View {
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) { private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) let ok = await connectContactViaAddress(contact.contactId, incognito)
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) await MainActor.run {
AlertManager.shared.showAlert(connReqSentAlert(.contact)) chatModel.chatId = contact.id
}
} }
} }
} }
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
} }
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
@@ -518,14 +513,15 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
) )
} }
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool { func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId) let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
if let alert = alert { if let alert = alert {
showAlert(alert) AlertManager.shared.showAlert(alert)
return false return false
} else if let contact = contact { } else if let contact = contact {
await MainActor.run { await MainActor.run {
ChatModel.shared.updateContact(contact) ChatModel.shared.updateContact(contact)
AlertManager.shared.showAlert(connReqSentAlert(.contact))
} }
return true return true
} }
@@ -566,11 +562,18 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
} }
} }
struct ErrorAlert {
var title: LocalizedStringKey
var message: LocalizedStringKey
}
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
if let r = error as? ChatResponse, switch error as? ChatResponse {
let alert = getNetworkErrorAlert(r) { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
return alert return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
} else { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
default:
return ErrorAlert(title: title, message: "Error: \(responseError(error))") return ErrorAlert(title: title, message: "Error: \(responseError(error))")
} }
} }
+115 -213
View File
@@ -11,21 +11,16 @@ 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
@State private var searchText = "" @State private var searchText = ""
@State private var searchShowingSimplexLink = false @State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil @State private var searchChatFilteredBySimplexLink: String? = nil
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var userPickerVisible = false @State private var userPickerVisible = false
@State private var showConnectDesktop = false @State private var showConnectDesktop = false
@State private var scrollToSearchBar = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
@@ -36,16 +31,21 @@ struct ChatListView: View {
} }
private var viewBody: some View { private var viewBody: some View {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) { ZStack(alignment: .topLeading) {
NavStackCompat( NavStackCompat(
isActive: Binding( isActive: Binding(
get: { chatModel.chatId != nil }, get: { chatModel.chatId != nil },
set: { active in set: { _ in }
if !active { chatModel.chatId = nil }
}
), ),
destination: chatView destination: chatView
) { chatListView } ) {
VStack {
if chatModel.chats.isEmpty {
onboardingButtons()
}
chatListView
}
}
if userPickerVisible { if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture { Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation { withAnimation {
@@ -65,14 +65,9 @@ struct ChatListView: View {
} }
private var chatListView: some View { private var chatListView: some View {
let tm = ToolbarMaterial.material(toolbarMaterial) VStack {
return withToolbar(tm) {
chatList chatList
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(searchMode || oneHandUI)
} }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.onDisappear() { withAnimation { userPickerVisible = false } } .onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable { .refreshable {
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
@@ -90,77 +85,11 @@ struct ChatListView: View {
secondaryButton: .cancel() secondaryButton: .cancel()
)) ))
} }
.safeAreaInset(edge: .top) { .listStyle(.plain)
if oneHandUI { Divider().background(tm) } .navigationBarTitleDisplayMode(.inline)
} .navigationBarHidden(searchMode)
.safeAreaInset(edge: .bottom) { .toolbar {
if oneHandUI { ToolbarItem(placement: .navigationBarLeading) {
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
}
}
}
static var hasHomeIndicator: Bool = {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.safeAreaInsets.bottom > 0
} else { false }
}()
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
if #available(iOS 16.0, *) {
if oneHandUI {
content()
.toolbarBackground(.hidden, for: .bottomBar)
.toolbar { bottomToolbar }
} else {
content()
.toolbarBackground(.automatic, for: .navigationBar)
.toolbarBackground(material)
.toolbar { topToolbar }
}
} else {
if oneHandUI {
content().toolbar { bottomToolbarGroup }
} else {
content().toolbar { topToolbar }
}
}
}
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
}
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItem(placement: .bottomBar) {
HStack {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
SubsStatusIndicator().padding(.bottom, padding)
Spacer()
trailingToolbarItem.padding(.bottom, padding)
}
.contentShape(Rectangle())
.onTapGesture { scrollToSearchBar = true }
}
}
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItemGroup(placement: .bottomBar) {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
SubsStatusIndicator().padding(.bottom, padding)
Spacer()
trailingToolbarItem.padding(.bottom, padding)
}
}
@ViewBuilder var leadingToolbarItem: some View {
let user = chatModel.currentUser ?? User.sampleData let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel)) ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
@@ -182,19 +111,39 @@ struct ChatListView: View {
} }
} }
} }
ToolbarItem(placement: .principal) {
@ViewBuilder var trailingToolbarItem: some View { HStack(spacing: 4) {
Text("Chats")
.font(.headline)
if chatModel.chats.count > 0 {
toggleFilterButton()
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning { switch chatModel.chatRunning {
case .some(true): NewChatMenuButton() case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
case .some(false): chatStoppedIcon() case .some(false): chatStoppedIcon()
case .none: EmptyView() case .none: EmptyView()
} }
} }
}
}
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 {
ScrollViewReader { scrollProxy in VStack {
List { List {
if !chatModel.chats.isEmpty { if !chatModel.chats.isEmpty {
ChatListSearchBar( ChatListSearchBar(
@@ -204,50 +153,25 @@ struct ChatListView: View {
searchShowingSimplexLink: $searchShowingSimplexLink, searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
) )
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
}
if !oneHandUICardShown {
OneHandUICard()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} }
ForEach(cs, id: \.viewId) { chat in ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat) ChatListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.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)
} }
.listStyle(.plain) }
.onChange(of: chatModel.chatId) { currentChatId in .onChange(of: chatModel.chatId) { _ in
if let chatId = chatModel.chatToTop, currentChatId != chatId { if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
chatModel.chatToTop = nil chatModel.chatToTop = nil
chatModel.popChat(chatId) chatModel.popChat(chatId)
} }
stopAudioPlayer()
}
.onChange(of: chatModel.currentUser?.userId) { _ in
stopAudioPlayer()
}
.onChange(of: scrollToSearchBar) { scrollToSearchBar in
if scrollToSearchBar {
Task { self.scrollToSearchBar = false }
withAnimation { scrollProxy.scrollTo("searchBar") }
}
}
} }
if cs.isEmpty && !chatModel.chats.isEmpty { if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats") Text("No filtered chats").foregroundColor(.secondary)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
} }
} }
} }
@@ -255,18 +179,51 @@ 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(theme.colors.primary) .foregroundColor(.accentColor)
}
private func onboardingButtons() -> some View {
VStack(alignment: .trailing, spacing: 0) {
Path { p in
p.move(to: CGPoint(x: 8, y: 0))
p.addLine(to: CGPoint(x: 16, y: 10))
p.addLine(to: CGPoint(x: 0, y: 10))
p.addLine(to: CGPoint(x: 8, y: 0))
}
.fill(Color.accentColor)
.frame(width: 20, height: 10)
.padding(.trailing, 12)
connectButton("Tap to start a new chat") {
newChatMenuOption = .newContact
}
Spacer()
Text("You have no chats")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
.padding(.trailing, 6)
.frame(maxHeight: .infinity)
}
private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.padding(.vertical, 10)
.padding(.horizontal, 20)
}
.background(Color.accentColor)
.foregroundColor(.white)
.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) ChatView(chat: chat).onAppear {
loadChat(chat: chat)
} }
} }
func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
} }
private func filteredChats() -> [Chat] { private func filteredChats() -> [Chat] {
@@ -275,20 +232,16 @@ struct ChatListView: View {
} else { } else {
let s = searchString() let s = searchString()
return s == "" && !showUnreadAndFavorites return s == "" && !showUnreadAndFavorites
? chatModel.chats.filter { chat in ? chatModel.chats
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
}
: chatModel.chats.filter { chat in : chatModel.chats.filter { chat in
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
switch cInfo { switch cInfo {
case let .direct(contact): case let .direct(contact):
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && ( return s == ""
s == ""
? filtered(chat) ? filtered(chat)
: (viewNameContains(cInfo, s) || : (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) || contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s)) contact.fullName.localizedLowercase.contains(s))
)
case let .group(gInfo): case let .group(gInfo):
return s == "" return s == ""
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited) ? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
@@ -321,76 +274,17 @@ struct ChatListView: View {
} }
} }
struct SubsStatusIndicator: View {
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
@State private var hasSess: Bool = false
@State private var task: Task<Void, Never>?
@State private var showServersSummary = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
var body: some View {
Button {
showServersSummary = true
} label: {
HStack(spacing: 4) {
Text("Chats").foregroundStyle(Color.primary).fixedSize().font(.headline)
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
}
}
}
.disabled(ChatModel.shared.chatRunning != true)
.onAppear {
startTask()
}
.onDisappear {
stopTask()
}
.appSheet(isPresented: $showServersSummary) {
ServersSummaryView()
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
}
private func startTask() {
task = Task {
while !Task.isCancelled {
if AppChatState.shared.value == .active {
do {
let (subs, hasSess) = try await getAgentSubsTotal()
await MainActor.run {
self.subs = subs
self.hasSess = hasSess
}
} catch let error {
logger.error("getSubsTotal error: \(responseError(error))")
}
}
try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
}
}
}
func stopTask() {
task?.cancel()
task = nil
}
}
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) {
@@ -398,7 +292,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 ? theme.colors.secondary : theme.colors.onBackground) .foregroundColor(searchShowingSimplexLink ? .secondary : .primary)
.disabled(searchShowingSimplexLink) .disabled(searchShowingSimplexLink)
.focused($searchFocussed) .focused($searchFocussed)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -407,24 +301,47 @@ 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(theme.colors.secondary) .foregroundColor(.secondary)
.background(Color(.tertiarySystemFill)) .background(Color(.tertiarySystemFill))
.cornerRadius(10.0) .cornerRadius(10.0)
if searchFocussed { if searchFocussed {
Text("Cancel") Text("Cancel")
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.onTapGesture { .onTapGesture {
searchText = "" searchText = ""
searchFocussed = false searchFocussed = false
} }
} else if m.chats.count > 0 {
toggleFilterButton()
} }
} }
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 }
@@ -459,21 +376,6 @@ 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,25 +11,19 @@ import SimpleXChat
struct ChatPreviewView: View { struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@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)
@State private var activeContentPreview: ActiveContentPreview? = nil
@State private var showFullscreenGallery: Bool = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
var body: some View { var body: some View {
let cItem = chat.chatItems.last let cItem = chat.chatItems.last
return HStack(spacing: 8) { return HStack(spacing: 8) {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) ChatInfoImage(chat: chat, size: 63)
chatPreviewImageOverlayIcon() chatPreviewImageOverlayIcon()
.padding([.bottom, .trailing], 1) .padding([.bottom, .trailing], 1)
} }
@@ -42,45 +36,18 @@ 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(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.top, 4) .padding(.top, 4)
} }
.padding(.bottom, 4) .padding(.bottom, 4)
.padding(.horizontal, 8) .padding(.horizontal, 8)
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
let chat = activeContentPreview?.chat ?? chat chatMessagePreview(cItem)
let ci = activeContentPreview?.ci ?? chat.chatItems.last
let mc = ci?.content.msgContent
HStack(alignment: .top) {
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
if let ci, showContentPreview {
chatItemContentPreview(chat, ci)
}
let mcIsVoice = switch mc { case .voice: true; default: false }
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
let hasFilePreview = if case .file = mc { true } else { false }
chatMessagePreview(cItem, hasFilePreview)
} else {
Spacer()
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: activeContentPreview) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: showFullscreenGallery) { _ in
checkActiveContentPreview(chat, ci, mc)
}
chatStatusImage() chatStatusImage()
.padding(.top, dynamicChatInfoSize * 1.44) .padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 8) .padding(.trailing, 8)
Spacer() Spacer()
@@ -90,33 +57,6 @@ struct ChatPreviewView: View {
.padding(.bottom, -8) .padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in .onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains deleting = contains
// Stop voice when deleting the chat
if contains, let ci = activeContentPreview?.ci {
VoiceItemState.stopVoiceInSmallView(chat.chatInfo, ci)
}
}
func checkActiveContentPreview(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
let playing = chatModel.stopPreviousRecPlay
if case .voice = activeContentPreview?.mc, playing == nil {
activeContentPreview = nil
} else if activeContentPreview == nil {
if case .image = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .video = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .voice = mc, let ci, let mc, let fileSource = ci.file?.fileSource, playing?.path.hasSuffix(fileSource.filePath) == true {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
} else if case .voice = activeContentPreview?.mc {
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
activeContentPreview = nil
}
} else if !showFullscreenGallery {
activeContentPreview = nil
}
} }
} }
@@ -154,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 ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary) case .memInvited: v.foregroundColor(deleting ? .secondary : chat.chatInfo.incognito ? .indigo : .accentColor)
case .memAccepted: v.foregroundColor(theme.colors.secondary) case .memAccepted: v.foregroundColor(.secondary)
default: if deleting { v.foregroundColor(theme.colors.secondary) } else { v } default: if deleting { v.foregroundColor(.secondary) } else { v }
} }
default: previewTitle(t) default: previewTitle(t)
} }
@@ -168,63 +108,52 @@ struct ChatPreviewView: View {
private var verifiedIcon: Text { private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" ")) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.baselineOffset(1) .baselineOffset(1)
.kerning(-2) .kerning(-2)
} }
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View { private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
let t = text let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8) .padding(.leading, 8)
.padding(.trailing, hasFilePreview ? 38 : 36) .padding(.trailing, 36)
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
if !showChatPreviews && !draft { if !showChatPreviews && !draft {
t.privacySensitive(true).redacted(reason: .privacy) t.privacySensitive(true).redacted(reason: .privacy)
} else { } else {
t t
} }
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat { if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount) unreadCountText(s.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2) .font(.caption)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding) .padding(.horizontal, 4)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) .frame(minWidth: 18, minHeight: 18)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? Color.accentColor : Color.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner) .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")
.resizable() .foregroundColor(.secondary)
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.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()
.scaledToFill() .scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) .frame(width: 18, height: 18)
.padding(.trailing, 1) .padding(.trailing, 1)
.foregroundColor(theme.colors.secondary.opacity(0.65)) .foregroundColor(.secondary.opacity(0.65))
} else { }
Color.clear.frame(width: 0)
} }
} }
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: theme.colors.primary) return image("rectangle.and.pencil.and.ellipsis", color: .accentColor)
+ attachment() + attachment()
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false)
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(" ")
@@ -243,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: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false)
// 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
@@ -267,18 +196,18 @@ struct ChatPreviewView: View {
} }
} }
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft { if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview) chatPreviewLayout(messageDraft(draft), draft: true)
} else if let cItem = cItem { } else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview) chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
} else { } else {
switch (chat.chatInfo) { switch (chat.chatInfo) {
case let .direct(contact): case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { if contact.activeConn == nil && contact.profile.contactLink != nil {
chatPreviewInfoText("Tap to Connect") chatPreviewInfoText("Tap to Connect")
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
} else if !contact.sndReady && contact.activeConn != nil { } else if !contact.ready && contact.activeConn != nil {
if contact.nextSendGrpInv { if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message") chatPreviewInfoText("send direct message")
} else if contact.active { } else if contact.active {
@@ -296,54 +225,6 @@ struct ChatPreviewView: View {
} }
} }
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) {
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
ZStack {
Image(systemName: "arrow.up.right")
.resizable()
.foregroundColor(Color.white)
.font(.system(size: 15, weight: .black))
.frame(width: 8, height: 8)
}
.frame(width: 16, height: 16)
.background(Color.black.opacity(0.25))
.cornerRadius(8)
}
.onTapGesture {
UIApplication.shared.open(preview.uri)
}
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>())
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: Binding.constant(true), smallViewSize: dynamicMediaSize)
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
}
default: EmptyView()
}
}
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View { @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)") ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
@@ -372,101 +253,51 @@ struct ChatPreviewView: View {
} }
@ViewBuilder private func chatStatusImage() -> some View { @ViewBuilder private func chatStatusImage() -> some View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
if contact.active && contact.activeConn != nil { if contact.active && contact.activeConn != nil {
NetworkStatusView(contact: contact, size: size) switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: 17, height: 17)
.foregroundColor(.secondary)
default:
ProgressView()
}
} else { } else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito)
} }
case .group: case .group:
if progressByTimeout { if progressByTimeout {
ProgressView() ProgressView()
} else { } else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito)
} }
default: default:
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito)
}
}
struct NetworkStatusView: View {
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@EnvironmentObject var theme: AppTheme
@ObservedObject var networkModel = NetworkModel.shared
let contact: Contact
let size: CGFloat
var body: some View {
let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize
switch (networkModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
} }
} }
} }
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View { @ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View {
if incognito { if incognito {
Image(systemName: "theatermasks") Image(systemName: "theatermasks")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: size, height: size) .frame(width: 22, height: 22)
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
} else { } else {
EmptyView() EmptyView()
} }
} }
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.cornerRadius(8)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
.padding(.vertical, size / 6)
.padding(.leading, 3)
.offset(x: 6)
}
func smallContentPreviewVoice(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(height: voiceMessageSizeBasedOnSquareSize(size))
.padding(.vertical, size / 6)
.padding(.leading, 8)
}
func smallContentPreviewFile(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.padding(.vertical, size / 7)
.padding(.leading, 5)
}
func unreadCountText(_ n: Int) -> Text { func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "") Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
} }
private struct ActiveContentPreview: Equatable {
var chat: Chat
var ci: ChatItem
var mc: MsgContent
static func == (lhs: ActiveContentPreview, rhs: ActiveContentPreview) -> Bool {
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
}
}
struct ChatPreviewView_Previews: PreviewProvider { struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
@@ -11,7 +11,6 @@ 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?
@@ -21,7 +20,7 @@ struct ContactConnectionInfo: View {
enum CCInfoAlert: Identifiable { enum CCInfoAlert: Identifiable {
case deleteInvitationAlert case deleteInvitationAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@@ -49,7 +48,7 @@ struct ContactConnectionInfo: View {
Section { Section {
if contactConnection.groupLinkId == nil { if contactConnection.groupLinkId == nil {
settingsRow("pencil", color: theme.colors.secondary) { settingsRow("pencil") {
TextField("Set contact name…", text: $localAlias) TextField("Set contact name…", text: $localAlias)
.autocapitalization(.none) .autocapitalization(.none)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
@@ -64,15 +63,14 @@ struct ContactConnectionInfo: View {
let connReqInv = contactConnection.connReqInv { let connReqInv = contactConnection.connReqInv {
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
incognitoEnabled() incognitoEnabled()
shareLinkButton(connReqInv, theme.colors.secondary) shareLinkButton(connReqInv)
oneTimeLinkLearnMoreButton(theme.colors.secondary) oneTimeLinkLearnMoreButton()
} else { } else {
incognitoEnabled() incognitoEnabled()
oneTimeLinkLearnMoreButton(theme.colors.secondary) oneTimeLinkLearnMoreButton()
} }
} footer: { } footer: {
sharedProfileInfo(contactConnection.incognito) sharedProfileInfo(contactConnection.incognito)
.foregroundColor(theme.colors.secondary)
} }
Section { Section {
@@ -84,7 +82,6 @@ struct ContactConnectionInfo: View {
} }
} }
} }
.modifier(ThemedBackground(grouped: true))
if #available(iOS 16, *) { if #available(iOS 16, *) {
v v
} else { } else {
@@ -102,7 +99,7 @@ struct ContactConnectionInfo: View {
} success: { } success: {
dismiss() dismiss()
} }
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
.onAppear { .onAppear {
@@ -152,7 +149,7 @@ struct ContactConnectionInfo: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("Incognito") Text("Incognito")
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.font(.system(size: 14)) .font(.system(size: 14))
} }
.onTapGesture { .onTapGesture {
@@ -167,24 +164,23 @@ struct ContactConnectionInfo: View {
} }
} }
private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View { private func shareLinkButton(_ connReqInvitation: String) -> some View {
Button { Button {
showShareSheet(items: [simplexChatLink(connReqInvitation)]) showShareSheet(items: [simplexChatLink(connReqInvitation)])
} label: { } label: {
settingsRow("square.and.arrow.up", color: secondaryColor) { settingsRow("square.and.arrow.up") {
Text("Share 1-time link") Text("Share 1-time link")
} }
} }
} }
private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View { private func oneTimeLinkLearnMoreButton() -> 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", color: secondaryColor) { settingsRow("info.circle") {
Text("Learn more") Text("Learn more")
} }
} }
@@ -12,10 +12,9 @@ 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
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@State private var localAlias = "" @State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var showContactConnectionInfo = false
var body: some View { var body: some View {
if case let .contactConnection(conn) = chat.chatInfo { if case let .contactConnection(conn) = chat.chatInfo {
@@ -30,7 +29,8 @@ struct ContactConnectionView: View {
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme)) .foregroundColor(Color(uiColor: .secondarySystemBackground))
.onTapGesture { showContactConnectionInfo = true }
} }
.frame(width: 63, height: 63) .frame(width: 63, height: 63)
.padding(.leading, 4) .padding(.leading, 4)
@@ -41,7 +41,7 @@ struct ContactConnectionView: View {
.font(.title3) .font(.title3)
.bold() .bold()
.allowsTightening(false) .allowsTightening(false)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.top, 1) .padding(.top, 1)
.padding(.bottom, 0.5) .padding(.bottom, 0.5)
@@ -54,14 +54,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(theme.colors.secondary) .foregroundColor(.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, theme.colors.secondary, size: dynamicSize(userFont).incognitoSize) incognitoIcon(contactConnection.incognito)
.padding(.top, 26) .padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
@@ -70,6 +70,9 @@ struct ContactConnectionView: View {
Spacer() Spacer()
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.appSheet(isPresented: $showContactConnectionInfo) {
ContactConnectionInfo(contactConnection: contactConnection)
}
} }
} }
} }
@@ -11,21 +11,19 @@ import SimpleXChat
struct ContactRequestView: View { struct ContactRequestView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var contactRequest: UserContactRequest var contactRequest: UserContactRequest
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) ChatInfoImage(chat: chat, size: 63)
.padding(.leading, 4) .padding(.leading, 4)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) { HStack(alignment: .top) {
Text(contactRequest.chatViewName) Text(contactRequest.chatViewName)
.font(.title3) .font(.title3)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.padding(.leading, 8) .padding(.leading, 8)
.frame(alignment: .topLeading) .frame(alignment: .topLeading)
Spacer() Spacer()
@@ -34,7 +32,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(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.bottom, 2) .padding(.bottom, 2)
@@ -1,52 +0,0 @@
//
// OneHandUICard.swift
// SimpleX (iOS)
//
// Created by EP on 06/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct OneHandUICard: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@State private var showOneHandUIAlert = false
var body: some View {
ZStack(alignment: .topTrailing) {
VStack(alignment: .leading, spacing: 8) {
Text("Toggle chat list:").font(.title3)
Toggle("Reachable chat toolbar", isOn: $oneHandUI)
}
Image(systemName: "multiply")
.foregroundColor(theme.colors.secondary)
.onTapGesture {
showOneHandUIAlert = true
}
}
.padding()
.background(theme.appColors.sentMessage)
.cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight)
.padding(.vertical, 12)
.alert(isPresented: $showOneHandUIAlert) {
Alert(
title: Text("Reachable chat toolbar"),
message: Text("You can change it in Appearance settings."),
dismissButton: .default(Text("Ok")) {
withAnimation {
oneHandUICardShown = true
}
}
)
}
}
}
#Preview {
OneHandUICard()
}
@@ -1,752 +0,0 @@
//
// 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
@EnvironmentObject var theme: AppTheme
@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
if AppChatState.shared.value == .active {
getServersSummary()
}
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
private 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 profiles").tag(PresentedUserCategory.allUsers)
Text("Current profile").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")
.foregroundColor(theme.colors.secondary)
.background(theme.colors.background)
}
}
private func smpSubsSection(_ totals: SMPTotals) -> some View {
Section {
infoRow("Active connections", numOrDash(totals.subs.ssActive))
infoRow("Total", numOrDash(totals.subs.total))
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess)
}
}
}
}
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, hasSess: srvSumm.sessionsOrNew.hasSess)
}
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
} 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")
}
}
}
struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color)
} else {
Image(systemName: "dot.radiowaves.up.forward")
.foregroundColor(color.opacity(opacity))
}
}
}
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
}
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
: (n * 4).rounded() / 4
}
let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return !online
? noConnColorAndPercent
: (
subs.total == 0 && !hasSess
? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start
: (
subs.ssActive == 0
? (
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
)
: ( // ssActive > 0
hasSess
? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
: (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
)
)
)
}
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("Active connections", numOrDash(subs.ssActive))
infoRow("Pending", numOrDash(subs.ssPending))
infoRow("Total", numOrDash(subs.total))
reconnectButton()
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
}
}
}
}
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("Connections") {
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))
}
Section {
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
} header: {
Text("Connection notifications")
} 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,10 +6,13 @@
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
@@ -18,6 +21,9 @@ 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 {
@@ -76,7 +82,7 @@ struct UserPicker: View {
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.background( .background(
Rectangle() Rectangle()
.fill(theme.colors.surface) .fill(fillColor)
.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)
) )
@@ -125,13 +131,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(theme.colors.onBackground) .foregroundColor(.primary)
.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 ? theme.colors.primary : theme.colors.secondary) unreadCounter(u.unreadCount, color: user.showNtfs ? .accentColor : .secondary)
} else if !user.showNtfs { } else if !user.showNtfs {
Image(systemName: "speaker.slash") Image(systemName: "speaker.slash")
} }
@@ -139,7 +145,7 @@ struct UserPicker: View {
.padding(.trailing) .padding(.trailing)
.padding([.leading, .vertical], 12) .padding([.leading, .vertical], 12)
}) })
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill))) .buttonStyle(PressedButtonStyle(defaultColor: fillColor, 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 {
@@ -150,13 +156,13 @@ struct UserPicker: View {
Spacer() Spacer()
Image(systemName: icon) Image(systemName: icon)
.symbolRenderingMode(.monochrome) .symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 22) .padding(.vertical, 22)
.frame(height: menuButtonHeight) .frame(height: menuButtonHeight)
} }
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill))) .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill)))
} }
} }
@@ -1,267 +0,0 @@
//
// ContactListNavLink.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 01/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactListNavLink: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var showDeletedChatIcon: Bool
@State private var alert: SomeAlert? = nil
@State private var actionSheet: SomeActionSheet? = nil
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var showContactRequestDialog = false
var body: some View {
let contactType = chatContactType(chat: chat)
Group {
switch (chat.chatInfo) {
case let .direct(contact):
switch contactType {
case .recent:
recentContactNavLink(contact)
case .chatDeleted:
deletedChatNavLink(contact)
case .card:
contactCardNavLink(contact)
default:
EmptyView()
}
case let .contactRequest(contactRequest):
contactRequestNavLink(contactRequest)
default:
EmptyView()
}
}
.alert(item: $alert) { $0.alert }
.actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
} else {
$0.content
}
}
}
func recentContactNavLink(_ contact: Contact) -> some View {
Button {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(contact.id)
}
} label: {
contactPreview(contact, titleColor: theme.colors.onBackground)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
func deletedChatNavLink(_ contact: Contact) -> some View {
Button {
Task {
await MainActor.run {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(contact.id)
}
}
}
} label: {
contactPreview(contact, titleColor: theme.colors.onBackground)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
func contactPreview(_ contact: Contact, titleColor: Color) -> some View {
HStack{
ProfileImage(imageStr: contact.image, size: 30)
previewTitle(contact, titleColor: titleColor)
Spacer()
HStack {
if showDeletedChatIcon && contact.chatDeleted {
Image(systemName: "archivebox")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.secondary.opacity(0.65))
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.foregroundColor(.secondary.opacity(0.65))
}
if contact.contactConnIncognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
}
}
}
}
@ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
(
contact.verified == true
? verifiedIcon + t
: t
)
.lineLimit(1)
}
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
func contactCardNavLink(_ contact: Contact) -> some View {
Button {
showConnectContactViaAddressDialog = true
} label: {
contactCardPreview(contact)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
Button("Use current profile") { connectContactViaAddress_(contact, false) }
Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
}
}
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok {
ItemsModel.shared.loadOpenChat(contact.id)
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
}
}
}
}
func contactCardPreview(_ contact: Contact) -> some View {
HStack{
ProfileImage(imageStr: contact.image, size: 30)
Text(chat.chatInfo.chatViewName)
.foregroundColor(.accentColor)
.lineLimit(1)
Spacer()
Image(systemName: "envelope")
.resizable()
.scaledToFill()
.frame(width: 14, height: 14)
.foregroundColor(.accentColor)
}
}
func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
Button {
showContactRequestDialog = true
} label: {
contactRequestPreview(contactRequest)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary)
Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: {
Label("Accept incognito", systemImage: "theatermasks")
}
.tint(.indigo)
Button {
alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest), id: "rejectContactRequestAlert")
} label: {
Label("Reject", systemImage: "multiply")
}
.tint(.red)
}
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
}
}
func contactRequestPreview(_ contactRequest: UserContactRequest) -> some View {
HStack{
ProfileImage(imageStr: contactRequest.image, size: 30)
Text(chat.chatInfo.chatViewName)
.foregroundColor(.accentColor)
.lineLimit(1)
Spacer()
Image(systemName: "checkmark")
.resizable()
.scaledToFill()
.frame(width: 14, height: 14)
.foregroundColor(.accentColor)
}
}
}
@@ -10,7 +10,6 @@ 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
@@ -21,14 +20,14 @@ struct ChatArchiveView: View {
let fileTs = chatArchiveTimeDefault.get() let fileTs = chatArchiveTimeDefault.get()
List { List {
Section { Section {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) { settingsRow("square.and.arrow.up") {
Button { Button {
showShareSheet(items: [fileUrl]) showShareSheet(items: [fileUrl])
} label: { } label: {
Text("Save archive") Text("Save archive")
} }
} }
settingsRow("trash", color: theme.colors.secondary) { settingsRow("trash") {
Button { Button {
showDeleteAlert = true showDeleteAlert = true
} label: { } label: {
@@ -37,10 +36,8 @@ 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,7 +35,6 @@ 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
@@ -64,7 +63,7 @@ struct DatabaseEncryptionView: View {
private func databaseEncryptionView() -> some View { private func databaseEncryptionView() -> some View {
Section { Section {
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : theme.colors.secondary) { settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .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 {
@@ -86,7 +85,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", color: theme.colors.secondary) { settingsRow("lock.rotation") {
Button(migration ? "Set passphrase" : "Update database passphrase") { Button(migration ? "Set passphrase" : "Update database passphrase") {
alert = currentKey == "" alert = currentKey == ""
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) ? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
@@ -103,7 +102,6 @@ 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 {
@@ -127,7 +125,6 @@ struct DatabaseEncryptionView: View {
} }
} }
} }
.foregroundColor(theme.colors.secondary)
.padding(.top, 1) .padding(.top, 1)
.font(.callout) .font(.callout)
} }
@@ -280,7 +277,6 @@ 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
@@ -291,7 +287,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 : theme.colors.secondary) ? (showStrength && key != "" ? PassphraseStrength(passphrase: key).color : .secondary)
: .red : .red
Image(systemName: valid ? (showKey ? "eye.slash" : "eye") : "exclamationmark.circle") Image(systemName: valid ? (showKey ? "eye.slash" : "eye") : "exclamationmark.circle")
.resizable() .resizable()
@@ -64,7 +64,7 @@ struct DatabaseErrorView: View {
case let .migrationError(mtrError): case let .migrationError(mtrError):
titleText("Incompatible database version") titleText("Incompatible database version")
fileNameText(dbFile) fileNameText(dbFile)
Text("Error: ") + Text(mtrErrorDescription(mtrError)) Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
} }
case let .errorSQL(dbFile, migrationSQLError): case let .errorSQL(dbFile, migrationSQLError):
titleText("Database error") titleText("Database error")
@@ -105,6 +105,15 @@ struct DatabaseErrorView: View {
Text("Migrations: \(ms.joined(separator: ", "))") Text("Migrations: \(ms.joined(separator: ", "))")
} }
static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
switch err {
case let .noDown(dbMigrations):
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
case let .different(appMigration, dbMigration):
return "different migration in the app/database: \(appMigration) / \(dbMigration)"
}
}
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View { private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit) PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
} }
@@ -15,7 +15,6 @@ enum DatabaseAlert: Identifiable {
case importArchive case importArchive
case archiveImported case archiveImported
case archiveImportedWithErrors(archiveErrors: [ArchiveError]) case archiveImportedWithErrors(archiveErrors: [ArchiveError])
case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError])
case deleteChat case deleteChat
case chatDeleted case chatDeleted
case deleteLegacyDatabase case deleteLegacyDatabase
@@ -30,7 +29,6 @@ enum DatabaseAlert: Identifiable {
case .importArchive: return "importArchive" case .importArchive: return "importArchive"
case .archiveImported: return "archiveImported" case .archiveImported: return "archiveImported"
case .archiveImportedWithErrors: return "archiveImportedWithErrors" case .archiveImportedWithErrors: return "archiveImportedWithErrors"
case .archiveExportedWithErrors: return "archiveExportedWithErrors"
case .deleteChat: return "deleteChat" case .deleteChat: return "deleteChat"
case .chatDeleted: return "chatDeleted" case .chatDeleted: return "chatDeleted"
case .deleteLegacyDatabase: return "deleteLegacyDatabase" case .deleteLegacyDatabase: return "deleteLegacyDatabase"
@@ -43,7 +41,6 @@ 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
@@ -85,10 +82,8 @@ 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 {
@@ -110,27 +105,24 @@ 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 : theme.colors.secondary let color: Color = unencrypted ? .orange : .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", color: theme.colors.secondary) { settingsRow("square.and.arrow.up") {
Button("Export database") { Button("Export database") {
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted { if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
alert = .exportProhibited alert = .exportProhibited
@@ -139,7 +131,7 @@ struct DatabaseView: View {
} }
} }
} }
settingsRow("square.and.arrow.down", color: theme.colors.secondary) { settingsRow("square.and.arrow.down") {
Button("Import database", role: .destructive) { Button("Import database", role: .destructive) {
showFileImporter = true showFileImporter = true
} }
@@ -148,37 +140,34 @@ 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", color: theme.colors.secondary) { settingsRow("archivebox") {
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", color: theme.colors.secondary) { settingsRow("trash.slash") {
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(header: Text("Old database").foregroundColor(theme.colors.secondary)) { Section("Old database") {
settingsRow("trash", color: theme.colors.secondary) { settingsRow("trash") {
Button("Delete old database") { Button("Delete old database") {
alert = .deleteLegacyDatabase alert = .deleteLegacyDatabase
} }
@@ -193,15 +182,12 @@ 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)
} }
} }
} }
@@ -267,18 +253,10 @@ struct DatabaseView: View {
title: Text("Chat database imported"), title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database") message: Text("Restart the app to use imported chat database")
) )
case let .archiveImportedWithErrors(errs): case .archiveImportedWithErrors:
return Alert( return Alert(
title: Text("Chat database imported"), title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs) message: Text("Restart the app to use imported chat database") + Text("\n") + Text("Some non-fatal errors occurred during import - you may see Chat console for more details.")
)
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath])
}
) )
case .deleteChat: case .deleteChat:
return Alert( return Alert(
@@ -359,16 +337,9 @@ struct DatabaseView: View {
progressIndicator = true progressIndicator = true
Task { Task {
do { do {
let (archivePath, archiveErrors) = try await exportChatArchive() let archivePath = try await exportChatArchive()
if archiveErrors.isEmpty {
showShareSheet(items: [archivePath]) showShareSheet(items: [archivePath])
await MainActor.run { progressIndicator = false } await MainActor.run { progressIndicator = false }
} else {
await MainActor.run {
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
progressIndicator = false
}
}
} catch let error { } catch let error {
await MainActor.run { await MainActor.run {
alert = .error(title: "Error exporting chat database", error: responseError(error)) alert = .error(title: "Error exporting chat database", error: responseError(error))
@@ -384,7 +355,6 @@ 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)
@@ -503,17 +473,6 @@ struct DatabaseView: View {
} }
} }
func archiveErrorsText(_ errs: [ArchiveError]) -> Text {
return Text("\n" + errs.map(showArchiveError).joined(separator: "\n"))
func showArchiveError(_ err: ArchiveError) -> String {
switch err {
case let .import(importError): importError
case let .fileError(file, fileError): "\(file): \(fileError)"
}
}
}
func stopChatAsync() async throws { func stopChatAsync() async throws {
try await apiStopChat() try await apiStopChat()
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
@@ -189,8 +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 {
await MainActor.run { await MainActor.run {
@@ -222,7 +221,7 @@ struct MigrateToAppGroupView: View {
} }
} }
func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [ArchiveError]) { func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
let archiveTime = Date.now let archiveTime = Date.now
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted)) let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
let archiveName = "simplex-chat.\(ts).zip" let archiveName = "simplex-chat.\(ts).zip"
@@ -232,14 +231,13 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [Archive
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)
let errs = try await apiExportArchive(config: config)
if storagePath == nil { if storagePath == nil {
deleteOldArchive() deleteOldArchive()
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
chatArchiveTimeDefault.set(archiveTime) chatArchiveTimeDefault.set(archiveTime)
} }
return (archivePath, errs) return archivePath
} }
func deleteOldArchive() { func deleteOldArchive() {

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