diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 62a3bbd2de..ed636d88fa 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -99,6 +99,16 @@ struct ChatInfoView: View { } } + Section("Preferences") { + NavigationLink { + ContactPreferencesView() + .navigationBarTitle("Contact preferences") + .navigationBarTitleDisplayMode(.large) + } label: { + Text("Contact preferences") + } + } + Section("Servers") { networkStatusRow() .onTapGesture { diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift new file mode 100644 index 0000000000..f3febd5dec --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -0,0 +1,82 @@ +// +// ContactPreferencesView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 13/11/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ContactPreferencesView: View { + @State var allowFullDeletion = ContactFeatureAllowed.yes + @State var allowVoice = ContactFeatureAllowed.yes + @State var prefs = ContactUserPreferences( + fullDelete: ContactUserPreference( + enabled: FeatureEnabled(forUser: true, forContact: true), + userPreference: .user(preference: Preference(allow: .yes)), + contactPreference: Preference(allow: .no) + ), + voice: ContactUserPreference( + enabled: FeatureEnabled(forUser: true, forContact: true), + userPreference: .user(preference: Preference(allow: .yes)), + contactPreference: Preference(allow: .no) + ) + ) + + var body: some View { + VStack { + List { + featureSection(.fullDelete, .yes, prefs.fullDelete, $allowFullDeletion) + featureSection(.voice, .yes, prefs.voice, $allowVoice) + + Section { + HStack { + Text("Reset") + Spacer() + Text("Save") + } + .foregroundColor(.accentColor) + .disabled(true) + } + .listRowBackground(Color.clear) + } + } + } + + private func featureSection(_ feature: Feature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding) -> some View { + let enabled = FeatureEnabled.enabled( + user: Preference(allow: allowFeature.wrappedValue.allowed), + contact: pref.contactPreference + ) + return Section { + Picker("You allow", selection: allowFeature) { + ForEach(ContactFeatureAllowed.values(userDefault)) { allow in + Text(allow.text) + } + } + .frame(height: 36) + HStack { + Text("Contact allows") + Spacer() + Text(pref.contactPreference.allow.text) + } + } header: { + HStack { + Image(systemName: "\(feature.icon).fill") + .foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red) + Text(feature.text) + } + } footer: { + Text(feature.enabledDescription(enabled)) + .frame(height: 36, alignment: .topLeading) + } + } +} + +struct ContactPreferencesView_Previews: PreviewProvider { + static var previews: some View { + ContactPreferencesView() + } +} diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index c851472ae8..8ab4797407 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -158,7 +158,7 @@ struct DatabaseView: View { Section { Picker("Delete messages after", selection: $chatItemTTL) { - ForEach([ChatItemTTL.none, ChatItemTTL.month, ChatItemTTL.week, ChatItemTTL.day]) { ttl in + ForEach(ChatItemTTL.values) { ttl in Text(ttl.deleteAfterText).tag(ttl) } if case .seconds = chatItemTTL { diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift new file mode 100644 index 0000000000..9b7f8c7882 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -0,0 +1,57 @@ +// +// PreferencesView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 13/11/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct PreferencesView: View { + @State var allowFullDeletion = FeatureAllowed.yes + @State var allowVoice = FeatureAllowed.yes + + var body: some View { + VStack { + List { + featureSection(.fullDelete, $allowFullDeletion) + featureSection(.voice, $allowVoice) + + Section { + HStack { + Text("Reset") + Spacer() + Text("Save") + } + .foregroundColor(.accentColor) + .disabled(true) + } + .listRowBackground(Color.clear) + } + } + } + + private func featureSection(_ feature: Feature, _ allowFeature: Binding) -> some View { + Section { + settingsRow(feature.icon) { + Picker(feature.text, selection: allowFeature) { + ForEach(FeatureAllowed.values) { allow in + Text(allow.text) + } + } + .frame(height: 36) + } + } footer: { + Text(feature.allowDescription(allowFeature.wrappedValue)) + .frame(height: 36, alignment: .topLeading) + } + } +} + +struct PreferencesView_Previews: PreviewProvider { + static var previews: some View { + PreferencesView() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 196bfa749c..3859d932dd 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -120,6 +120,13 @@ struct SettingsView: View { } Section("Settings") { + NavigationLink { + PreferencesView() + .navigationTitle("Your preferences") + } label: { + settingsRow("list.bullet") { Text("Chat preferences") } + } + NavigationLink { NotificationsView() .navigationTitle("Notifications") diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index f9d59bd7d7..b317b60927 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -68,6 +68,8 @@ 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; + 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; }; + 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; 5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; }; 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8D2827126500B3292C /* OnboardingView.swift */; }; @@ -267,6 +269,8 @@ 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; + 5CADE79929211BB900072E13 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPreferencesView.swift; sourceTree = ""; }; 5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; 5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CB0BA8D2827126500B3292C /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; @@ -428,6 +432,7 @@ 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, 5CE4407127ADB1D0007B033A /* Emoji.swift */, + 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */, ); path = Chat; sourceTree = ""; @@ -571,6 +576,7 @@ 5CB346E62868D76D001FD2EF /* NotificationsView.swift */, 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, + 5CADE79929211BB900072E13 /* PreferencesView.swift */, 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, @@ -956,6 +962,7 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, + 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, @@ -978,6 +985,7 @@ 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, + 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 8745c4f6c8..5f5dc545dd 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -117,6 +117,181 @@ extension NamedChat { public typealias ChatId = String +public struct FullPreferences: Decodable { + public var fullDelete: Preference + public var voice: Preference + + public init(fullDelete: Preference, voice: Preference) { + self.fullDelete = fullDelete + self.voice = voice + } +} + +public struct Preference: Codable { + public var allow: FeatureAllowed + + public init(allow: FeatureAllowed) { + self.allow = allow + } +} + +public struct ContactUserPreferences: Decodable { + public var fullDelete: ContactUserPreference + public var voice: ContactUserPreference + + public init(fullDelete: ContactUserPreference, voice: ContactUserPreference) { + self.fullDelete = fullDelete + self.voice = voice + } +} + +public struct ContactUserPreference: Decodable { + public var enabled: FeatureEnabled + public var userPreference: ContactUserPref + public var contactPreference: Preference + + public init(enabled: FeatureEnabled, userPreference: ContactUserPref, contactPreference: Preference) { + self.enabled = enabled + self.userPreference = userPreference + self.contactPreference = contactPreference + } +} + +public struct FeatureEnabled: Decodable { + public var forUser: Bool + public var forContact: Bool + + public init(forUser: Bool, forContact: Bool) { + self.forUser = forUser + self.forContact = forContact + } + + public static func enabled(user: Preference, contact: Preference) -> FeatureEnabled { + switch (user.allow, contact.allow) { + case (.always, .no): return FeatureEnabled(forUser: false, forContact: true) + case (.no, .always): return FeatureEnabled(forUser: true, forContact: false) + case (_, .no): return FeatureEnabled(forUser: false, forContact: false) + case (.no, _): return FeatureEnabled(forUser: false, forContact: false) + default: return FeatureEnabled(forUser: true, forContact: true) + } + } +} + +public enum ContactUserPref: Decodable { + case contact(preference: Preference) // contact override is set + case user(preference: Preference) // global user default is used +} + +public enum Feature { + case fullDelete + case voice + + public var values: [Feature] { [.fullDelete, .voice] } + + public var id: Self { self } + + public var text: LocalizedStringKey { + switch self { + case .fullDelete: return "Full deletion" + case .voice: return "Voice messages" + } + } + + public var icon: String { + switch self { + case .fullDelete: return "trash.slash" + case .voice: return "speaker.wave.2" + } + } + + public func allowDescription(_ allowed: FeatureAllowed) -> LocalizedStringKey { + switch self { + case .fullDelete: + switch allowed { + case .always: return "Allow your contacts to irreversibly delete sent messages." + case .yes: return "Allow irreversible message deletion only if your contact allows it to you." + case .no: return "Contacts can mark messages for deletion; you will be able to view them." + } + case .voice: + switch allowed { + case .always: return "Allow your contacts to send voice messages." + case .yes: return "Allow voice messages only if your contact allows them." + case .no: return "Prohibit sending voice messages." + } + } + } + + public func enabledDescription(_ enabled: FeatureEnabled) -> LocalizedStringKey { + switch self { + case .fullDelete: + return enabled.forUser && enabled.forContact + ? "Both you and your contact can irreversibly delete sent messages." + : enabled.forUser + ? "Only you can irreversibly delete messages (your contact can mark them for deletion)." + : enabled.forContact + ? "Only your contact can irreversibly delete messages (you can mark them for deletion)." + : "Irreversible message deletion is prohibited in this chat." + case .voice: + return enabled.forUser && enabled.forContact + ? "Both you and your contact can send voice messages." + : enabled.forUser + ? "Only you can send voice messages." + : enabled.forContact + ? "Only your contact can send voice messages." + : "Voice messages are prohibited in this chat." + } + } +} + +public enum ContactFeatureAllowed: Identifiable, Hashable { + case userDefault(FeatureAllowed) + case always + case yes + case no + + public static func values(_ def: FeatureAllowed) -> [ContactFeatureAllowed] { + [.userDefault(def) , .always, .yes, .no] + } + + public var id: Self { self } + + public var allowed: FeatureAllowed { + switch self { + case let .userDefault(def): return def + case .always: return .always + case .yes: return .yes + case .no: return .no + } + } + + public var text: String { + switch self { + case let .userDefault(def): return String.localizedStringWithFormat(NSLocalizedString("default (%@)", comment: "pref value"), def.text) + case .always: return NSLocalizedString("always", comment: "pref value") + case .yes: return NSLocalizedString("yes", comment: "pref value") + case .no: return NSLocalizedString("no", comment: "pref value") + } + } +} + +public enum FeatureAllowed: String, Codable, Identifiable { + case always + case yes + case no + + public static var values: [FeatureAllowed] { [.always, .yes, .no] } + + public var id: Self { self } + + public var text: String { + switch self { + case .always: return NSLocalizedString("always", comment: "pref value") + case .yes: return NSLocalizedString("yes", comment: "pref value") + case .no: return NSLocalizedString("no", comment: "pref value") + } + } +} + public enum ChatInfo: Identifiable, Decodable, NamedChat { case direct(contact: Contact) case group(groupInfo: GroupInfo) @@ -1561,6 +1736,8 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable { case seconds(_ seconds: Int64) case none + public static var values: [ChatItemTTL] { [.none, .month, .week, .day] } + public var id: Self { self } public init(_ seconds: Int64?) {