Compare commits

..

1 Commits

Author SHA1 Message Date
shum 4e5ee981e5 Revert "ui: comment smp proxy ui (#4204)"
This reverts commit a0d6ae15ab.
2024-05-21 14:04:40 +00:00
906 changed files with 23354 additions and 79714 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

+4 -27
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
@@ -36,7 +34,7 @@ struct ContentView: View {
@State private var waitingForOrPassedAuth = true @State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil @State private var chatListActionSheet: ChatListActionSheet? = nil
private let callTopPadding: CGFloat = 40 private let callTopPadding: CGFloat = 50
private enum ChatListActionSheet: Identifiable { private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet) case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -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(systemInDarkThemeCurrently)
}
.onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
// so when a user changes his global theme from dark to light or back, the app will adapt to it
reactOnDarkThemeChanges(scheme == .dark)
}
.onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle()
}
} }
@ViewBuilder private func contentView() -> some View { @ViewBuilder private func contentView() -> some View {
@@ -207,7 +184,7 @@ struct ContentView: View {
CallDuration(call: call) CallDuration(call: call)
} }
.padding(.horizontal) .padding(.horizontal)
.frame(height: callTopPadding) .frame(height: callTopPadding - 10)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1))) .background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture { .onTapGesture {
chatModel.activeCallViewIsCollapsed = false chatModel.activeCallViewIsCollapsed = false
@@ -247,8 +224,8 @@ struct ContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity ) .frame(maxWidth: .infinity, maxHeight: .infinity )
.background( .background(
Rectangle() Rectangle()
.fill(theme.colors.background) .fill(.background)
) )
} }
private func mainView() -> some View { private func mainView() -> some View {
+134 -324
View File
@@ -43,103 +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
}
}
}
/// ChatItemWithMenu can depend on previous or next item for it's appearance
/// This dummy model is used to force an update of all chat items,
/// when they might have changed appearance.
class ChatItemDummyModel: ObservableObject {
static let shared = ChatItemDummyModel()
func sendUpdate() { objectWillChange.send() }
}
final class ChatModel: ObservableObject { final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage? @Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false @Published var setDeliveryReceipts = false
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
@Published var currentUser: User? { @Published var currentUser: User?
didSet {
ThemeManager.applyTheme(currentThemeDefault.get())
}
}
@Published var users: [UserInfo] = [] @Published var users: [UserInfo] = []
@Published var chatInitialized = false @Published var chatInitialized = false
@Published var chatRunning: Bool? @Published var chatRunning: Bool?
@@ -151,15 +59,16 @@ final class ChatModel: ObservableObject {
@Published var contentViewAccessAuthenticated: Bool = false @Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest? @Published var laRequest: LocalAuthRequest?
// list of chat "previews" // list of chat "previews"
@Published private(set) 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] = []
@@ -191,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)
@@ -200,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
@@ -267,47 +176,18 @@ final class ChatModel: ObservableObject {
} }
} }
func populateGroupMembersIndexes() {
groupMembersIndexes.removeAll()
for (i, member) in groupMembers.enumerated() {
groupMembersIndexes[member.groupMemberId] = i
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? { func getGroupMember(_ groupMemberId: Int64) -> GMember? {
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) } chats.insert(chat, at: position)
} else {
addChat_(chat, at: 0)
} }
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
}
func addChat_(_ chat: Chat, at position: Int = 0) {
chats.insert(chat, at: position)
} }
func updateChatInfo(_ cInfo: ChatInfo) { func updateChatInfo(_ cInfo: ChatInfo) {
@@ -365,10 +245,26 @@ final class ChatModel: ObservableObject {
} }
} }
func updateChats(_ newChats: [ChatData]) { func updateChats(with newChats: [ChatData]) {
chats = newChats.map { Chat($0) } for i in 0..<newChats.count {
let c = newChats[i]
if let j = getChatIndex(c.id) {
let chat = chats[j]
chat.chatInfo = c.chatInfo
chat.chatItems = c.chatItems
chat.chatStats = c.chatStats
if i != j {
if chatId != c.chatInfo.id {
popChat_(j, to: i)
} else if i == 0 {
chatToTop = c.chatInfo.id
}
}
} else {
addChat(Chat(c), at: i)
}
}
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
popChatCollector.clear()
} }
// func addGroup(_ group: SimpleXChat.Group) { // func addGroup(_ group: SimpleXChat.Group) {
@@ -376,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 {
@@ -399,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]))
} }
@@ -416,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 {
@@ -427,26 +326,24 @@ 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) {
_updateChatItem(at: i, with: cItem) withAnimation {
ChatItemDummyModel.shared.sendUpdate() _updateChatItem(at: i, with: cItem)
}
return false return false
} else { } else {
var ci = cItem withAnimation(itemAnimation()) {
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { var ci = cItem
ci.meta.itemStatus = status if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true return true
} }
@@ -460,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 {
@@ -469,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) {
@@ -491,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
@@ -526,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
} }
@@ -548,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) {
@@ -573,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
} }
@@ -587,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
@@ -622,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, // update preview
let itemIndex = getChatItemIndex(cItem), decreaseUnreadCounter(cInfo)
im.reversedChatItems[itemIndex].isRcvNew { // update current chat
await MainActor.run { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withTransaction(Transaction()) { markChatItemRead_(i)
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
}
}
}
}
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 {
@@ -775,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)
@@ -791,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)
} }
@@ -801,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)
} }
@@ -817,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)
} }
@@ -827,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)
} }
} }
@@ -862,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 {
@@ -893,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
}
} }
} }
@@ -929,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
@@ -987,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) {
+143 -335
View File
@@ -92,29 +92,25 @@ 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)") }
} Task {
Task { await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
}
} }
return resp return resp
} }
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse { func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
await withCheckedContinuation { cont in await withCheckedContinuation { cont in
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log)) cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
} }
} }
@@ -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)
} m.updateChatInfo(chat.chatInfo)
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) m.reversedChatItems = chat.chatItems.reversed()
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo)
}
} catch let error { } catch let error {
logger.error("loadChat error: \(responseError(error))") logger.error("loadChat error: \(responseError(error))")
} }
@@ -357,17 +341,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r throw r
} }
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl) let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
} }
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages) let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
return await processSendMessageCmd(toChatType: type, cmd: cmd) return await processSendMessageCmd(toChatType: type, cmd: cmd)
} }
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? { private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
let chatModel = ChatModel.shared let chatModel = ChatModel.shared
let r: ChatResponse let r: ChatResponse
if toChatType == .direct { if toChatType == .direct {
@@ -380,13 +364,10 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
} }
}) })
r = await chatSendCmd(cmd, bgTask: false) r = await chatSendCmd(cmd, bgTask: false)
if case let .newChatItems(_, aChatItems) = r { if case let .newChatItem(_, aChatItem) = r {
let cItems = aChatItems.map { $0.chatItem } cItem = aChatItem.chatItem
if let cItemLast = cItems.last { chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
cItem = cItemLast return cItem
chatModel.messageDelivery[cItemLast.id] = endTask
}
return cItems
} }
if let networkErrorAlert = networkErrorAlert(r) { if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert) AlertManager.shared.showAlert(networkErrorAlert)
@@ -397,18 +378,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
return nil return nil
} else { } else {
r = await chatSendCmd(cmd, bgDelay: msgDelay) r = await chatSendCmd(cmd, bgDelay: msgDelay)
if case let .newChatItems(_, aChatItems) = r { if case let .newChatItem(_, aChatItem) = r {
return aChatItems.map { $0.chatItem } return aChatItem.chatItem
} }
sendMessageErrorAlert(r) sendMessageErrorAlert(r)
return nil return nil
} }
} }
func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? { func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages)) let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
createChatItemsErrorAlert(r) createChatItemErrorAlert(r)
return nil return nil
} }
@@ -416,15 +397,15 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))") logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Error sending message", title: "Error sending message",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
private func createChatItemsErrorAlert(_ r: ChatResponse) { private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItems 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))"
) )
} }
@@ -440,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
} }
@@ -560,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))
} }
@@ -585,18 +561,6 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Gro
throw r throw r
} }
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
}
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
}
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats { func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId)) let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats } if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
@@ -676,13 +640,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r throw r
} }
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
throw r
}
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
let userId = try currentUserId("apiConnectPlan") let userId = try currentUserId("apiConnectPlan")
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
@@ -715,7 +672,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)
@@ -725,7 +682,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."
@@ -758,7 +715,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))"
) )
} }
} }
@@ -775,38 +732,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(
@@ -816,39 +757,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 }
@@ -911,21 +819,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))
@@ -973,7 +866,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."
@@ -984,7 +877,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
@@ -1010,7 +903,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))
} }
} }
@@ -1020,7 +913,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))
} }
} }
@@ -1100,7 +993,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))"
) )
} }
} }
@@ -1167,9 +1060,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
} }
} }
@@ -1177,17 +1079,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)
}
}
}
} }
} }
@@ -1228,18 +1120,12 @@ func apiEndCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiEndCall(contact: contact)) try await sendCommandOkResp(.apiEndCall(contact: contact))
} }
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] { func apiGetCallInvitations() throws -> [RcvCallInvitation] {
let r = chatSendCmdSync(.apiGetCallInvitations) let r = chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs } if case let .callInvitations(invs) = r { return invs }
throw r throw r
} }
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
let r = await chatSendCmd(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws { func apiCallStatus(_ contact: Contact, _ status: String) async throws {
if let callStatus = WebRTCCallStatus.init(rawValue: status) { if let callStatus = WebRTCCallStatus.init(rawValue: status) {
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus)) try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
@@ -1289,7 +1175,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))")
} }
@@ -1330,7 +1216,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
} }
@@ -1379,7 +1265,7 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats return ChatModel.shared.chats
.compactMap{ $0.chatInfo.contact } .compactMap{ $0.chatInfo.contact }
.filter{ c in c.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) } .filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) }
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() } .sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
} }
@@ -1436,26 +1322,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), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
logger.error("getAgentServersSummary error: \(String(describing: r))")
throw r
}
func resetAgentServersStats() async throws {
try await sendCommandOkResp(.resetAgentServersStats)
}
private func currentUserId(_ funcName: String) throws -> Int64 { private func currentUserId(_ funcName: String) throws -> Int64 {
if let userId = ChatModel.shared.currentUser?.userId { if let userId = ChatModel.shared.currentUser?.userId {
return userId return userId
@@ -1475,7 +1341,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()
@@ -1527,16 +1394,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) {
Task { try await 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 {
@@ -1561,7 +1427,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
} }
@@ -1607,7 +1474,7 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress() m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL() m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats() let chats = try apiGetChats()
m.updateChats(chats) m.chats = chats.map { Chat.init($0) }
} }
private func getUserChatDataAsync() async throws { private func getUserChatDataAsync() async throws {
@@ -1619,12 +1486,12 @@ private func getUserChatDataAsync() async throws {
await MainActor.run { await MainActor.run {
m.userAddress = userAddress m.userAddress = userAddress
m.chatItemTTL = chatItemTTL m.chatItemTTL = chatItemTTL
m.updateChats(chats) m.chats = chats.map { Chat.init($0) }
} }
} else { } else {
await MainActor.run { await MainActor.run {
m.userAddress = nil m.userAddress = nil
m.updateChats([]) m.chats = []
} }
} }
} }
@@ -1674,7 +1541,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):
@@ -1697,7 +1563,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 {
@@ -1709,19 +1575,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)
@@ -1754,7 +1607,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)
} }
@@ -1762,49 +1615,47 @@ 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 .newChatItems(user, chatItems): case let .newChatItem(user, aChatItem):
for chatItem in chatItems { let cInfo = aChatItem.chatInfo
let cInfo = chatItem.chatInfo let cItem = aChatItem.chatItem
let cItem = chatItem.chatItem await MainActor.run {
await MainActor.run { if active(user) {
if active(user) { m.addChatItem(cInfo, cItem)
m.addChatItem(cInfo, cItem) } else if cItem.isRcvNew && cInfo.ntfsEnabled {
} else if cItem.isRcvNew && cInfo.ntfsEnabled { m.increaseUnreadCounter(user: user)
m.increaseUnreadCounter(user: user)
}
} }
if let file = cItem.autoReceiveFile() { }
Task { if let file = cItem.autoReceiveFile() {
await receiveFile(user: user, fileId: file.fileId, auto: true) Task {
} await receiveFile(user: user, fileId: file.fileId, auto: true)
}
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
} }
} }
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
case let .chatItemStatusUpdated(user, aChatItem): case let .chatItemStatusUpdated(user, aChatItem):
let cInfo = aChatItem.chatInfo let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem let cItem = aChatItem.chatItem
@@ -1813,15 +1664,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
if let endTask = m.messageDelivery[cItem.id] { if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus { switch cItem.meta.itemStatus {
case .sndNew: ()
case .sndSent: endTask() case .sndSent: endTask()
case .sndRcvd: endTask()
case .sndErrorAuth: endTask() case .sndErrorAuth: endTask()
case .sndError: endTask() case .sndError: endTask()
case .sndWarning: endTask() default: ()
case .rcvNew: ()
case .rcvRead: ()
case .invalid: ()
} }
} }
case let .chatItemUpdated(user, aChatItem): case let .chatItemUpdated(user, aChatItem):
@@ -1832,25 +1678,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(deletedChatItem.chatInfo, deletedChatItem.chatItem)
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
} }
} }
case let .receivedGroupInvitation(user, groupInfo, _, _): case let .receivedGroupInvitation(user, groupInfo, _, _):
@@ -1930,7 +1772,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):
@@ -1972,15 +1814,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, _):
@@ -1997,15 +1835,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
case let .sndFileCompleteXFTP(user, aChatItem, _): case let .sndFileCompleteXFTP(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileError(user, aChatItem, _, _): case let .sndFileError(user, aChatItem, _):
if let aChatItem = aChatItem { if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) } Task { cleanupFile(aChatItem) }
} }
case let .sndFileWarning(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .callInvitation(invitation): case let .callInvitation(invitation):
await MainActor.run { await MainActor.run {
m.callInvitations[invitation.contact.id] = invitation m.callInvitations[invitation.contact.id] = invitation
@@ -2103,30 +1937,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) {
@@ -2155,13 +1971,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))")
} }
@@ -2184,30 +1999,23 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
} }
} }
func refreshCallInvitations() async throws { func refreshCallInvitations() throws {
let m = ChatModel.shared let m = ChatModel.shared
let callInvitations = try await apiGetCallInvitations() let callInvitations = try justRefreshCallInvitations()
await MainActor.run { if let (chatId, ntfAction) = m.ntfCallInvitationAction,
m.callInvitations = callsByChat(callInvitations) let invitation = m.callInvitations.removeValue(forKey: chatId) {
if let (chatId, ntfAction) = m.ntfCallInvitationAction, m.ntfCallInvitationAction = nil
let invitation = m.callInvitations.removeValue(forKey: chatId) { CallController.shared.callAction(invitation: invitation, action: ntfAction)
m.ntfCallInvitationAction = nil } else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
CallController.shared.callAction(invitation: invitation, action: ntfAction) activateCall(invitation)
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
activateCall(invitation)
}
} }
} }
func justRefreshCallInvitations() throws { func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
let callInvitations = try apiGetCallInvitationsSync() let m = ChatModel.shared
ChatModel.shared.callInvitations = callsByChat(callInvitations) let callInvitations = try apiGetCallInvitations()
} m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
return callInvitations
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
result, inv in result[inv.contact.id] = inv
}
} }
func activateCall(_ callInvitation: RcvCallInvitation) { func activateCall(_ callInvitation: RcvCallInvitation) {
-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)
+10 -14
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
@@ -83,11 +81,9 @@ struct SimpleXApp: App {
if appState != .stopped { if appState != .stopped {
startChatAndActivate { startChatAndActivate {
if appState.inactive && chatModel.chatRunning == true { if appState.inactive && chatModel.chatRunning == true {
Task { updateChats()
await updateChats() if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { updateCallInvitations()
await updateCallInvitations()
}
} }
} }
} }
@@ -132,16 +128,16 @@ struct SimpleXApp: App {
} }
} }
private func updateChats() async { private func updateChats() {
do { do {
let chats = try await apiGetChatsAsync() let chats = try apiGetChats()
await MainActor.run { chatModel.updateChats(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 {
await MainActor.run { chatModel.ntfContactRequest = nil } chatModel.ntfContactRequest = nil
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo { if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
} }
@@ -151,9 +147,9 @@ struct SimpleXApp: App {
} }
} }
private func updateCallInvitations() async { private func updateCallInvitations() {
do { do {
try await refreshCallInvitations() try refreshCallInvitations()
} catch let error { } catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))") logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
} }
-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(_ inDarkNow: Bool) {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
if let parsed = UIImage(base64Encoded: image),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
copy.imageFile = filename
return copy
} else {
return ThemeWallpaper()
}
} else {
return self
}
}
func withFilledWallpaperBase64() -> ThemeWallpaper {
let aw = toAppWallpaper()
let type = aw.type
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper (
preset: preset,
scale: scale,
scaleType: scaleType,
background: aw.background?.toReadableHex(),
tint: aw.tint?.toReadableHex(),
image: image,
imageFile: nil
)
}
}
extension ThemeModeOverride {
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
let w: ThemeWallpaper
switch wallpaperType {
case let WallpaperType.preset(filename, scale):
let p = PresetWallpaper.from(filename)
w = ThemeWallpaper(
preset: filename,
scale: scale ?? wallpaper?.scale,
scaleType: nil,
background: p?.background[base]?.toReadableHex(),
tint: p?.tint[base]?.toReadableHex(),
image: nil,
imageFile: nil
)
case WallpaperType.image:
w = ThemeWallpaper(
preset: nil,
scale: nil,
scaleType: WallpaperScaleType.fill,
background: Color.clear.toReadableHex(),
tint: Color.clear.toReadableHex(),
image: nil,
imageFile: nil
)
default:
w = ThemeWallpaper()
}
let wallpaper: ThemeWallpaper? = if let wallpaper {
ThemeWallpaper(
preset: wallpaper.preset,
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
background: wallpaper.background != w.background ? wallpaper.background : nil,
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
image: wallpaper.image,
imageFile: wallpaper.imageFile
)
} else {
nil
}
return ThemeModeOverride(
mode: self.mode,
colors: ThemeColors(
primary: colors.primary != tc.primary ? colors.primary : nil,
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
background: colors.background != tc.background ? colors.background : nil,
surface: colors.surface != tc.surface ? colors.surface : nil,
title: colors.title != tc.title ? colors.title : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
),
wallpaper: wallpaper
)
}
}
-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 ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
}
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
let themeName = currentThemeDefault.get()
let nonSystemThemeName = nonSystemThemeName()
let defaultTheme = defaultActiveTheme(appSettingsTheme)
let baseTheme = switch nonSystemThemeName {
case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
}
let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
}
let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
PresetWallpaper.from(filename)?.colors[baseTheme.base]
} else if let wallpaper = perChatTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else if let wallpaper = perUserTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else {
if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
}
let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
return ActiveTheme(
name: themeName,
base: baseTheme.base,
colors: colors,
appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
)
}
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
let wType = current.wallpaper.type
let wBackground = current.wallpaper.background
let wTint = current.wallpaper.tint
let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
nil
} else {
ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
}
return ThemeOverrides(
themeId: "",
base: current.base,
colors: ThemeColors.from(current.colors, current.appColors),
wallpaper: w
)
}
static func applyTheme(_ theme: String) {
currentThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
let tint = UIColor(CurrentColors.colors.primary)
if SceneDelegate.windowStatic?.tintColor != tint {
SceneDelegate.windowStatic?.tintColor = tint
}
// applyNavigationBarColors(CurrentColors.toAppTheme())
}
static func adjustWindowStyle() {
let style = switch currentThemeDefault.get() {
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
default: UIUserInterfaceStyle.dark
}
if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
}
}
// static func applyNavigationBarColors(_ theme: AppTheme) {
// let baseColors = switch theme.base {
// case DefaultTheme.LIGHT: LightColorPaletteApp
// case DefaultTheme.DARK: DarkColorPaletteApp
// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
// case DefaultTheme.BLACK: BlackColorPaletteApp
// }
// let isDefaultColor = baseColors.title == theme.appColors.title
//
// let title = UIColor(theme.appColors.title)
// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
// } else {
// UINavigationBar.appearance().titleTextAttributes = nil
// UINavigationBar.appearance().largeTitleTextAttributes = nil
// }
// }
static func changeDarkTheme(_ theme: String) {
systemDarkThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
}
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(currentThemeDefault.get())
}
static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding<ThemeModeOverride>) {
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
}
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let theme = overrides.sameTheme(type, baseTheme.themeName)
var prevValue = theme ?? ThemeOverrides(base: baseTheme)
prevValue.wallpaper = if let type {
if case WallpaperType.empty = type {
nil as ThemeWallpaper?
} else {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
}
} else {
nil
}
pref.set(overrides.replace(prevValue))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(nonSystemThemeName)
}
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
let overrides = themeOverridesDefault.get()
let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
wallpaper
} else {
overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
}
guard let sameWallpaper else {
if let type {
var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
} else {
// Make an empty wallpaper to override any top level ones
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
}
return true
}
var type = sameWallpaper.toAppWallpaper().type
if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
type = WallpaperType.image(filename, scale, scaleType)
} else {
logger.error("Error while copying wallpaper from global overrides to chat overrides")
return false
}
}
var prevValue = pref.wrappedValue
var w = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
prevValue.colors = ThemeColors()
prevValue.wallpaper = w
pref.wrappedValue = prevValue
return true
}
static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.wallpaper = if let type {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
} else {
nil
}
pref.wrappedValue = prevValue
}
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let wallpaper = theme.wallpaper?.importFromString()
let nonSystemThemeName = theme.base.themeName
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
if let imageFile = prevValue.wallpaper?.imageFile {
try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
}
prevValue.base = theme.base
prevValue.colors = theme.colors
prevValue.wallpaper = wallpaper
pref.set(overrides.replace(prevValue))
currentThemeDefault.set(nonSystemThemeName)
var currentThemeIds = currentThemeIdsDefault.get()
currentThemeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(currentThemeIds)
applyTheme(nonSystemThemeName)
}
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = nonSystemThemeName()
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
var prevValue = overrides.getTheme(themeId)
else { return }
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.set(overrides.replace(prevValue))
applyTheme(currentThemeDefault.get())
}
static func resetAllThemeColors(_ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.wrappedValue = prevValue
}
static func removeTheme(_ themeId: String?) {
var themes = themeOverridesDefault.get().map { $0 }
themes.removeAll(where: { $0.themeId == themeId })
themeOverridesDefault.set(themes)
}
}
@@ -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.
// //
@@ -186,7 +186,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
logger.debug("CallController: started chat") logger.debug("CallController: started chat")
self.shouldSuspendChat = true self.shouldSuspendChat = true
// There are no invitations in the model, as it was processed by NSE // There are no invitations in the model, as it was processed by NSE
try? justRefreshCallInvitations() _ = try? justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat") logger.debug("CallController: updated call invitations chat")
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
// Extract the call information from the push notification payload // Extract the call information from the push notification payload
@@ -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)
}) })
@@ -411,15 +411,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
func endCall() { func endCall() {
if #available(iOS 16.0, *) {
_endCall()
} else {
// Fixes `connection.close()` getting locked up in iOS15
DispatchQueue.global(qos: .utility).async { self._endCall() }
}
}
private func _endCall() {
guard let call = activeCall.wrappedValue else { return } guard let call = activeCall.wrappedValue else { return }
logger.debug("WebRTCClient: ending the call") logger.debug("WebRTCClient: ending the call")
activeCall.wrappedValue = nil activeCall.wrappedValue = nil
@@ -9,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)
let t = Text(cInfo.displayName).font(.headline) VStack {
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t) let t = Text(cInfo.displayName).font(.headline)
.lineLimit(1) (cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in .lineLimit(1)
VStack(spacing: 0) { if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
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())
} }
} }
File diff suppressed because it is too large Load Diff
@@ -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,48 +11,42 @@ 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 { let metaReserve = edited
fileIndicator() ? " "
.onTapGesture(perform: fileAction) : " "
} else { Button(action: fileAction) {
let metaReserve = edited HStack(alignment: .bottom, spacing: 6) {
? " " fileIndicator()
: " " .padding(.top, 5)
Button(action: fileAction) { .padding(.bottom, 3)
HStack(alignment: .bottom, spacing: 6) { if let file = file {
fileIndicator() let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
.padding(.top, 5) VStack(alignment: .leading, spacing: 2) {
.padding(.bottom, 3) Text(file.fileName)
if let file = file { .lineLimit(1)
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) .multilineTextAlignment(.leading)
VStack(alignment: .leading, spacing: 2) { .foregroundColor(.primary)
Text(file.fileName) Text(prettyFileSize + metaReserve)
.lineLimit(1) .font(.caption)
.multilineTextAlignment(.leading) .lineLimit(1)
.foregroundColor(theme.colors.onBackground) .multilineTextAlignment(.leading)
Text(prettyFileSize + metaReserve) .foregroundColor(.secondary)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
} }
} else {
Text(metaReserve)
} }
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
} }
.disabled(!itemInteractive) .padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
} }
.disabled(!itemInteractive)
} }
private var itemInteractive: Bool { private var itemInteractive: Bool {
@@ -60,18 +54,16 @@ struct CIFileView: View {
switch (file.fileStatus) { switch (file.fileStatus) {
case .sndStored: return file.fileProtocol == .local case .sndStored: return file.fileProtocol == .local
case .sndTransfer: return false case .sndTransfer: return false
case .sndComplete: return true case .sndComplete: return false
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,40 +108,11 @@ struct CIFileView: View {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource) saveCryptoFile(fileSource)
} }
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
case .sndStored: case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored") logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource) saveCryptoFile(fileSource)
} }
case .sndComplete:
logger.debug("CIFileView fileAction - in .sndComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError):
logger.debug("CIFileView fileAction - in .sndWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: break default: break
} }
} }
@@ -173,10 +136,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 +150,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 +162,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,8 +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
@Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var groupInvitation: CIGroupInvitation var groupInvitation: CIGroupInvitation
@@ -43,10 +42,10 @@ struct CIGroupInvitationView: View {
.overlay(DetermineWidth()) .overlay(DetermineWidth())
( (
Text(chatIncognito ? "Tap to join incognito" : "Tap to join") Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(inProgress ? 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, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -54,7 +53,7 @@ struct CIGroupInvitationView: View {
( (
groupInvitationText() groupInvitationText()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -66,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).modifier(ChatTailPadding()) } .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
@@ -99,7 +99,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View { private func groupInfoView(_ action: Bool) -> some View {
var color: Color var color: Color
if action && !inProgress { if action && !inProgress {
color = chatIncognito ? .indigo : 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,26 +82,7 @@ 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()
}
} }
} }
@@ -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,9 +21,10 @@ 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) { .sheet(isPresented: $showJSON) {
invalidJSONView(json) invalidJSONView(json)
} }
} }
@@ -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,10 +11,8 @@ import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color 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
@@ -31,24 +29,24 @@ struct CIMetaView: View {
switch meta.itemStatus { switch meta.itemStatus {
case let .sndSent(sndProgress): case let .sndSent(sndProgress):
switch sndProgress { switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
} }
case let .sndRcvd(_, sndProgress): case let .sndRcvd(_, sndProgress):
switch sndProgress { switch sndProgress {
case .complete: case .complete:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
} }
case .partial: case .partial:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
} }
} }
default: default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
} }
} }
} }
@@ -65,13 +63,11 @@ func ciMetaText(
chatTTL: Int?, chatTTL: Int?,
encrypted: Bool?, encrypted: Bool?,
color: Color = .clear, color: Color = .clear,
primaryColor: Color = .accentColor,
transparent: Bool = false, transparent: Bool = false,
sent: SentCheckmark? = nil, sent: SentCheckmark? = nil,
showStatus: Bool = true, showStatus: Bool = true,
showEdited: Bool = true, showEdited: Bool = true,
showViaProxy: Bool, showViaProxy: Bool
showTimesamp: Bool
) -> Text { ) -> Text {
var r = Text("") var r = Text("")
if showEdited, meta.itemEdited { if showEdited, meta.itemEdited {
@@ -89,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))
@@ -107,9 +103,7 @@ func ciMetaText(
if let enc = encrypted { if let enc = encrypted {
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
} }
if showTimesamp { r = r + meta.timestampText.foregroundColor(color)
r = r + meta.timestampText.foregroundColor(color)
}
return r.font(.caption) return r.font(.caption)
} }
@@ -118,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,9 +13,7 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
struct CIRcvDecryptionError: View { struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
@@ -28,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 {
@@ -63,46 +61,43 @@ 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))
} }
} }
} }
@ViewBuilder private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
Group { if case let .direct(contact) = chat.chatInfo,
if case let .direct(contact) = chat.chatInfo, let contactStats = contact.activeConn?.connectionStats {
let contactStats = contact.activeConn?.connectionStats { if contactStats.ratchetSyncAllowed {
if contactStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) {
decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncContactConnection(contact) }
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
} }
} else if case let .group(groupInfo) = chat.chatInfo, } else if !contactStats.ratchetSyncSupported {
case let .groupRcv(groupMember) = chatItem.chatDir, decryptionErrorItemFixButton(syncSupported: false) {
let mem = m.getGroupMember(groupMember.groupMemberId), alert = .syncNotSupportedContactAlert
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
} }
} else { } else {
basicDecryptionErrorItem() basicDecryptionErrorItem()
} }
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
} }
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
} }
private func basicDecryptionErrorItem() -> some View { private func basicDecryptionErrorItem() -> some View {
@@ -119,22 +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, showTimesamp: showTimestamp) + 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))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -145,14 +142,16 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + 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))
.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) {
.onTapGesture { imageView(uiImage)
.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)
@@ -81,64 +91,21 @@ struct CIVideoView: View {
default: () default: ()
} }
} }
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
} }
} else if !file.showStatusIconInSmallView { }
durationProgress()
}
if let file = file, showDownloadButton(file.fileStatus) {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
}
} }
} }
} }
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
} }
private func 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,22 +131,20 @@ 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) { if urlDecrypted != nil {
if urlDecrypted != nil { videoPlaying = true
videoPlaying = true player?.play()
player?.play()
}
} }
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} }
.disabled(!canBePlayed) } label: {
} else { playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
videoDecryptionProgress()
} }
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
} }
} }
} }
@@ -182,6 +152,7 @@ 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,170 +153,84 @@ 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 {
switch playbackState { case .noPlayback:
case .noPlayback: Button {
playPauseIcon("play.fill", theme.colors.primary) if let recordingSource = getLoadedFileSource(recordingFile) {
.onTapGesture { startPlayback(recordingSource)
if let recordingSource = getLoadedFileSource(recordingFile) { }
startPlayback(recordingSource) } label: {
} playPauseIcon("play.fill")
}
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 { case .playing:
switch playbackState { Button {
case .noPlayback: audioPlayer?.pause()
Button { playbackState = .paused
if let recordingSource = getLoadedFileSource(recordingFile) { } label: {
startPlayback(recordingSource) playPauseIcon("pause.fill")
} }
} label: { case .paused:
playPauseIcon("play.fill", theme.colors.primary) Button {
} audioPlayer?.play()
case .playing: playbackState = .playing
Button { } label: {
audioPlayer?.pause() playPauseIcon("play.fill")
playbackState = .paused
notifyStateChange()
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
} }
} }
} }
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 { Button {
if sizeMultiplier != 1 { Task {
playPauseIcon(icon, theme.colors.primary) if let user = chatModel.currentUser {
.onTapGesture { await receiveFile(user: user, fileId: recordingFile.fileId)
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
} else {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
} }
} }
} } label: {
} playPauseIcon(icon)
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} }
} }
private struct ProgressCircle: View { private struct ProgressCircle: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
@@ -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
@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 { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .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).modifier(ChatTailPadding()) } .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,8 +26,6 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
@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
@@ -67,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)
@@ -81,15 +79,15 @@ 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 {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
} }
} }
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 {
@@ -104,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,74 +35,98 @@ 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) {
.padding(.leading, 2) searchFieldView(text: $searchText, focussed: $searchFocused)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase .padding(.leading, 2)
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) } let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
ForEach(chats) { chat in let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { filterChatSearched($0, s) }
forwardListChatView(chat) ForEach(chats) { chat in
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id)) Divider()
forwardListNavLinkView(chat)
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
}
} }
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(uiColor: .systemBackground))
.cornerRadius(12)
.padding(.horizontal)
} }
.modifier(ThemedBackground(grouped: true)) .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 { dismiss()
alert = SomeAlert( if chat.id == fromChatInfo.id {
alert: mkAlert( composeState = ComposeState(
title: "Cannot forward message", message: composeState.message,
message: "Selected chat preferences prohibit this message." preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
), contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
id: "forward prohibited by preferences" )
)
} else { } else {
dismiss() composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
if chat.id == fromChatInfo.id { chatModel.chatId = chat.id
composeState = ComposeState(
message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
)
} else {
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(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,31 +402,35 @@ 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)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
Image(systemName: icon) Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67)) .foregroundColor(statusColor)
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
} }
default: } else {
Image(systemName: icon) Image(systemName: "ellipsis")
.foregroundColor(statusColor) .foregroundColor(Color.secondary)
} }
} }
@@ -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 -62
View File
@@ -9,39 +9,37 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
extension EnvironmentValues {
struct ShowTimestamp: EnvironmentKey {
static let defaultValue: Bool = true
}
var showTimestamp: Bool {
get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue }
}
}
struct ChatItemView: View { struct ChatItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var 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 {
@@ -52,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 {
@@ -64,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
@@ -125,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()
@@ -159,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)
} }
} }
@@ -207,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()
} }
@@ -215,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()
} }
} }
@@ -751,7 +732,6 @@ struct ComposeView: View {
case .linkPreview: case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(mediaPreviews: media): case let .mediaPreviews(mediaPreviews: media):
// TODO batch send: batch media previews
let last = media.count - 1 let last = media.count - 1
if last >= 0 { if last >= 0 {
for i in 0..<last { for i in 0..<last {
@@ -866,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
@@ -888,26 +867,22 @@ struct ComposeView: View {
} }
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
if let chatItems = chat.chatInfo.chatType == .local if let chatItem = chat.chatInfo.chatType == .local
? await apiCreateChatItems( ? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
noteFolderId: chat.chatInfo.apiId, : await apiSendMessage(
composedMessages: [ComposedMessage(fileSource: file, msgContent: mc)]
)
: await apiSendMessages(
type: chat.chatInfo.chatType, type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId, id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc,
live: live, live: live,
ttl: ttl, ttl: ttl
composedMessages: [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)]
) { ) {
await MainActor.run { await MainActor.run {
chatModel.removeLiveDummy(animated: false) chatModel.removeLiveDummy(animated: false)
for chatItem in chatItems { chatModel.addChatItem(chat.chatInfo, chatItem)
chatModel.addChatItem(chat.chatInfo, chatItem)
}
} }
// UI only supports sending one item at a time return chatItem
return chatItems.first
} }
if let file = file { if let file = file {
removeFile(file.filePath) removeFile(file.filePath)
@@ -916,21 +891,18 @@ struct ComposeView: View {
} }
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? { func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
if let chatItems = await apiForwardChatItems( if let chatItem = await apiForwardChatItem(
toChatType: chat.chatInfo.chatType, toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId, toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType, fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId, fromChatId: fromChatInfo.apiId,
itemIds: [forwardedItem.id], itemId: forwardedItem.id,
ttl: ttl ttl: ttl
) { ) {
await MainActor.run { await MainActor.run {
for chatItem in chatItems { chatModel.addChatItem(chat.chatInfo, chatItem)
chatModel.addChatItem(chat.chatInfo, chatItem)
}
} }
// TODO batch send: forward multiple messages return chatItem
return chatItems.first
} }
return nil return nil
} }
@@ -1093,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 {
addGroupMembersView() NavigationView {
.navigationBarBackButtonHidden() addGroupMembersView()
.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,116 +190,44 @@ 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() AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
}
}
}
} label: { } label: {
Label("Invite members", systemImage: "plus") Label("Invite members", systemImage: "plus")
} }
} }
private func addMembersDestinationView() -> some View {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
}
}
}
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 {
groupLinkView() NavigationView {
.navigationBarBackButtonHidden() groupLinkView()
.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
@@ -36,9 +35,7 @@ struct GroupMemberInfoView: View {
case abortSwitchAddressAlert case abortSwitchAddressAlert
case syncConnectionForceAlert case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert) case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String) case error(title: LocalizedStringKey, error: LocalizedStringKey)
case someAlert(alert: SomeAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String { var id: String {
switch self { switch self {
@@ -52,8 +49,6 @@ struct GroupMemberInfoView: View {
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "someAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
} }
} }
@@ -67,11 +62,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,153 +73,142 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View { private func groupMemberInfoView() -> some View {
ZStack { ZStack {
let member = groupMember.wrapped VStack {
List { let member = groupMember.wrapped
groupMemberInfoHeader(member) List {
.listRowBackground(Color.clear) groupMemberInfoHeader(member)
.listRowSeparator(.hidden) .listRowBackground(Color.clear)
.padding(.bottom, 18)
infoActionButtons(member) if member.memberActive {
.padding(.horizontal) Section {
.frame(maxWidth: .infinity) if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
.frame(height: infoViewActionButtonHeight) knownDirectChatButton(chat)
.listRowBackground(Color.clear) } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
.listRowSeparator(.hidden) if let contactId = member.memberContactId {
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
if member.memberActive { createMemberContactButton()
Section { }
if let code = connectionCode { verifyCodeButton(code) } }
if let connStats = connectionStats, if let code = connectionCode { verifyCodeButton(code) }
connStats.ratchetSyncAllowed { if let connStats = connectionStats,
synchronizeConnectionButton() connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
}
if let contactLink = member.contactLink { if let contactLink = member.contactLink {
Section { Section {
SimpleXLinkQRCode(uri: contactLink) SimpleXLinkQRCode(uri: contactLink)
Button { Button {
showShareSheet(items: [simplexChatLink(contactLink)]) showShareSheet(items: [simplexChatLink(contactLink)])
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
if let contactId = member.memberContactId { if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink)
}
} else {
connectViaAddressButton(contactLink) connectViaAddressButton(contactLink)
} }
} header: {
Text("Address")
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
}
}
Section("Member") {
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
}
.frame(height: 36)
} else { } else {
connectViaAddressButton(contactLink) infoRow("Role", member.memberRole.text)
} }
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) { // TODO invited by - need to get contact by contact id
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
}
}
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
if let conn = member.activeConn { 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) 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) infoRow("Connection", connLevelDesc)
} }
Button ("Debug delivery") { }
Task {
do { if let connStats = connectionStats {
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId)) Section("Servers") {
await MainActor.run { alert = .queueInfo(info: info) } // TODO network connection status
} catch let e { Button("Change receiving address") {
logger.error("apiContactQueueInfo error: \(responseError(e))") alert = .switchAddressAlert
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
} }
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
}
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
} }
} }
} }
} .navigationBarHidden(true)
.navigationBarHidden(true) .onAppear {
.onAppear { if #unavailable(iOS 16) {
if #unavailable(iOS 16) { // this condition prevents re-setting picker
// this condition prevents re-setting picker if !justOpened { return }
if !justOpened { return } }
} justOpened = false
justOpened = false DispatchQueue.main.async {
DispatchQueue.main.async { newRole = member.memberRole
newRole = member.memberRole do {
do { let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) _ = chatModel.upsertGroupMember(groupInfo, mem)
_ = chatModel.upsertGroupMember(groupInfo, mem) connectionStats = stats
connectionStats = stats connectionCode = code
connectionCode = code } catch let error {
} catch let error { logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") }
} }
} }
} .onChange(of: newRole) { newRole in
.onChange(of: newRole) { newRole in if newRole != member.memberRole {
if newRole != member.memberRole { alert = .changeMemberRoleAlert(mem: member, role: newRole)
alert = .changeMemberRoleAlert(mem: member, role: newRole) }
}
.onChange(of: member.memberRole) { role in
newRole = role
} }
}
.onChange(of: member.memberRole) { role in
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
@@ -240,9 +223,7 @@ struct GroupMemberInfoView: View {
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .queueInfo(info): return queueInfoAlert(info) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .someAlert(a): return a.alert
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 +232,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 +248,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)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
Button {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
} DispatchQueue.main.async {
} chatModel.chatId = chat.id
}
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
do {
let chat = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
} }
} catch let 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 +285,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
} chatModel.setContactNetworkStatus(memberContact, .connected)
NetworkModel.shared.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 +298,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 +359,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 +408,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 +566,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
@@ -35,7 +34,8 @@ struct GroupPreferencesView: View {
featureSection(.reactions, $preferences.reactions.enable) featureSection(.reactions, $preferences.reactions.enable)
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
featureSection(.files, $preferences.files.enable, $preferences.files.role) featureSection(.files, $preferences.files.enable, $preferences.files.role)
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) // TODO enable simplexLinks preference in 5.8
// featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
featureSection(.history, $preferences.history.enable) featureSection(.history, $preferences.history.enable)
if groupInfo.canEdit { if groupInfo.canEdit {
@@ -74,7 +74,7 @@ struct GroupPreferencesView: View {
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View { private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
Section { Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : 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 {
@@ -102,6 +102,8 @@ struct GroupPreferencesView: View {
} }
} }
.frame(height: 36) .frame(height: 36)
// remove in v5.8
.disabled(true)
} }
} else { } else {
settingsRow(icon, color: color) { settingsRow(icon, color: color) {
@@ -112,19 +114,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,342 +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
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem>
@Binding var scrollState: ReverseListScrollModel.State
/// Closure, that returns user interface for a given item
let content: (ChatItem) -> Content
let loadPage: () -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
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 }
var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
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, ChatItem>(
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: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
)
// Sets content offset on initial load
if itemCount == 0 {
tableView.setContentOffset(
CGPoint(x: 0, y: -InvertedTableView.inset),
animated: false
)
}
itemCount = items.count
updateVisibleItems()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateVisibleItems()
}
private func updateVisibleItems() {
ChatView.FloatingButtonModel.shared.visibleItems.send(
(tableView.indexPathsForVisibleRows ?? [])
.compactMap { indexPath -> String? in
let relativeFrame = tableView.superview!.convert(
tableView.rectForRow(at: indexPath),
from: tableView
)
// Checks that the cell is visible accounting for the added insets
let isVisible =
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
return indexPath.item < representer.items.count && isVisible
? representer.items[indexPath.item].viewId
: nil
}
)
}
}
/// `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: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(ChatItem.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: ChatItem.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 {
@@ -86,24 +64,17 @@ 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,29 @@ 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) { .sheet(isPresented: $showContactConnectionInfo) {
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 +408,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,13 +437,37 @@ 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) { .sheet(isPresented: $showInvalidJSON) {
invalidJSONView(json) invalidJSONView(json)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
} }
@@ -472,26 +475,16 @@ 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 {
Alert( Alert(
title: Text("Delete pending connection?"), title: Text("Delete pending connection?"),
@@ -518,14 +511,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 +560,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))")
} }
} }
+150 -265
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,111 +85,65 @@ 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) let user = chatModel.currentUser ?? User.sampleData
} ZStack(alignment: .topTrailing) {
} ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
} .padding(.trailing, 4)
let allRead = chatModel.users
static var hasHomeIndicator: Bool = { .filter { u in !u.user.activeUser && !u.user.hidden }
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, .allSatisfy { u in u.unreadCount == 0 }
let window = windowScene.windows.first { if !allRead {
window.safeAreaInsets.bottom > 0 unreadBadge(size: 12)
} else { false } }
}() }
.onTapGesture {
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View { if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
if #available(iOS 16.0, *) { withAnimation {
if oneHandUI { userPickerVisible.toggle()
content() }
.toolbarBackground(.hidden, for: .bottomBar) } else {
.toolbar { bottomToolbar } showSettings = true
} else { }
content() }
.toolbarBackground(.automatic, for: .navigationBar) }
.toolbarBackground(material) ToolbarItem(placement: .principal) {
.toolbar { topToolbar } HStack(spacing: 4) {
} Text("Chats")
} else { .font(.headline)
if oneHandUI { if chatModel.chats.count > 0 {
content().toolbar { bottomToolbarGroup } toggleFilterButton()
} else { }
content().toolbar { topToolbar } }
} .frame(maxWidth: .infinity, alignment: .center)
} }
} ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning {
@ToolbarContentBuilder var topToolbar: some ToolbarContent { case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem } case .some(false): chatStoppedIcon()
ToolbarItem(placement: .principal) { SubsStatusIndicator() } case .none: EmptyView()
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
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding(.trailing, 4)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
unreadBadge(size: 12)
}
}
.onTapGesture {
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
withAnimation {
userPickerVisible.toggle()
} }
} else {
showSettings = true
} }
} }
} }
@ViewBuilder var trailingToolbarItem: some View { private func toggleFilterButton() -> some View {
switch chatModel.chatRunning { Button {
case .some(true): NewChatMenuButton() showUnreadAndFavorites = !showUnreadAndFavorites
case .some(false): chatStoppedIcon() } label: {
case .none: EmptyView() 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,67 +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 { ForEach(cs, id: \.viewId) { chat in
OneHandUICard() ChatListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .padding(.trailing, -16)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
.listRowBackground(Color.clear)
}
.offset(x: -8)
} else {
ForEach(cs, id: \.viewId) { chat in
VStack(spacing: .zero) {
Divider()
.padding(.leading, 16)
ChatListNavLink(chat: chat)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.background { theme.colors.background } // Hides default list selection colour
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
} }
.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)
} }
} }
} }
@@ -272,40 +179,69 @@ 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] {
if let linkChatId = searchChatFilteredBySimplexLink { if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId } return chatModel.chats.filter { $0.id == linkChatId }
} 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)
@@ -338,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) {
@@ -415,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)
@@ -424,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 }
@@ -476,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,
@@ -518,21 +403,21 @@ func chatStoppedIcon() -> some View {
struct ChatListView_Previews: PreviewProvider { struct ChatListView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.updateChats([ chatModel.chats = [
ChatData( Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
), ),
ChatData( Chat(
chatInfo: ChatInfo.sampleData.group, chatInfo: ChatInfo.sampleData.group,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")] chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")]
), ),
ChatData( Chat(
chatInfo: ChatInfo.sampleData.contactRequest, chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: [] chatItems: []
) )
]) ]
return Group { return Group {
ChatListView(showSettings: Binding.constant(false)) ChatListView(showSettings: Binding.constant(false))
.environmentObject(chatModel) .environmentObject(chatModel)
@@ -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) let s = chat.chatStats
} if s.unreadCount > 0 || s.unreadChat {
} unreadCountText(s.unreadCount)
.font(.caption)
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View { .foregroundColor(.white)
let s = chat.chatStats .padding(.horizontal, 4)
if s.unreadCount > 0 || s.unreadChat { .frame(minWidth: 18, minHeight: 18)
unreadCountText(s.unreadCount) .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? Color.accentColor : Color.secondary)
.font(userFont <= .xxxLarge ? .caption : .caption2) .cornerRadius(10)
.foregroundColor(.white) } else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
.padding(.horizontal, dynamicSize(userFont).unreadPadding) Image(systemName: "speaker.slash.fill")
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) .foregroundColor(.secondary)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) } else if chat.chatInfo.chatSettings?.favorite ?? false {
.cornerRadius(dynamicSize(userFont).unreadCorner) Image(systemName: "star.fill")
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local { .resizable()
Image(systemName: "speaker.slash.fill") .scaledToFill()
.resizable() .frame(width: 18, height: 18)
.scaledToFill() .padding(.trailing, 1)
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) .foregroundColor(.secondary.opacity(0.65))
.foregroundColor(theme.colors.secondary) }
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.padding(.trailing, 1)
.foregroundColor(theme.colors.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())
}
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())
}
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()
}
+22 -19
View File
@@ -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)
) )
@@ -85,18 +91,15 @@ struct UserPicker: View {
.padding(8) .padding(8)
.opacity(userPickerVisible ? 1.0 : 0.0) .opacity(userPickerVisible ? 1.0 : 0.0)
.onAppear { .onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed. do {
if case .active = scenePhase { // This check prevents the call of listUsers after the app is suspended, and the database is closed.
Task { if case .active = scenePhase {
do { m.users = try listUsers()
let users = try await listUsersAsync() }
await MainActor.run { m.users = users } } catch let error {
} catch { logger.error("Error loading users \(responseError(error))")
logger.error("Error loading users \(responseError(error))") }
} }
}
}
}
} }
private func userView(_ u: UserInfo) -> some View { private func userView(_ u: UserInfo) -> some View {
@@ -128,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")
} }
@@ -142,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 {
@@ -153,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)
} }

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