// // GeneralSettings.swift // Monal // // Created by Vaidik Dubey on 22/03/24. // Copyright © 2024 monal-im.org. All rights reserved. // import ViewExtractor struct SettingsToggle: View where T: View { let value: Binding let contents: T init(isOn value: Binding, @ViewBuilder contents: @escaping () -> T) { self.value = value self.contents = contents() } var body:some View { VStack(alignment: .leading, spacing: 0) { Extract(contents) { views in if views.count == 0 { Text("") } else { Toggle(isOn: value) { views[0] .font(.body) } if views.count > 1 { Group { ForEach(views[1...]) { view in view .foregroundColor(Color(UIColor.secondaryLabel)) .font(.footnote) } }.fixedSize(horizontal: false, vertical: true).frame(maxWidth: .infinity, alignment: .leading) } } } } } } func getNotificationPrivacyOption(_ option: NotificationPrivacySettingOption) -> String { switch option{ case .DisplayNameAndMessage: return NSLocalizedString("Display Name And Message", comment: "") case .DisplayOnlyName: return NSLocalizedString("Display Only Name", comment: "") case .DisplayOnlyPlaceholder: return NSLocalizedString("Display Only Placeholder", comment: "") } } class GeneralSettingsDefaultsDB: ObservableObject { @defaultsDB("NotificationPrivacySetting") var notificationPrivacySetting: Int @defaultsDB("OMEMODefaultOn") var omemoDefaultOn:Bool @defaultsDB("AutodeleteInterval") var AutodeleteInterval: Int @defaultsDB("SendLastUserInteraction") var sendLastUserInteraction: Bool @defaultsDB("SendLastChatState") var sendLastChatState: Bool @defaultsDB("SendReceivedMarkers") var sendReceivedMarkers: Bool @defaultsDB("SendDisplayedMarkers") var sendDisplayedMarkers: Bool @defaultsDB("ShowGeoLocation") var showGeoLocation: Bool @defaultsDB("ShowURLPreview") var showURLPreview: Bool @defaultsDB("useInlineSafari") var useInlineSafari: Bool @defaultsDB("webrtcAllowP2P") var webrtcAllowP2P: Bool @defaultsDB("webrtcUseFallbackTurn") var webrtcUseFallbackTurn: Bool @defaultsDB("allowVersionIQ") var allowVersionIQ: Bool @defaultsDB("allowNonRosterContacts") var allowNonRosterContacts: Bool @defaultsDB("allowCallsFromNonRosterContacts") var allowCallsFromNonRosterContacts: Bool @defaultsDB("AutodownloadFiletransfers") var autodownloadFiletransfers : Bool @defaultsDB("AutodownloadFiletransfersWifiMaxSize") var autodownloadFiletransfersWifiMaxSize : UInt @defaultsDB("AutodownloadFiletransfersMobileMaxSize") var autodownloadFiletransfersMobileMaxSize : UInt @defaultsDB("ImageUploadQuality") var imageUploadQuality : Float @defaultsDB("showKeyboardOnChatOpen") var showKeyboardOnChatOpen: Bool @defaultsDB("useDnssecForAllConnections") var useDnssecForAllConnections: Bool @defaultsDB("uploadImagesOriginal") var uploadImagesOriginal: Bool @defaultsDB("hardlinkFiletransfersIntoDocuments") var hardlinkFiletransfersIntoDocuments: Bool @defaultsDB("showAdvancedUI") var showAdvancedUI: Bool } struct GeneralSettings: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() var body: some View { Form { Section(header:Text("General Settings")) { NavigationLink(destination: LazyClosureView(UserInterfaceSettings())) { HStack{ Image(systemName: "hand.tap.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) Text("User Interface") } } NavigationLink(destination: LazyClosureView(SecuritySettings())) { HStack{ Image(systemName: "shield.checkerboard") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) Text("Security") } } NavigationLink(destination: LazyClosureView(PrivacySettings())) { HStack{ Image(systemName: "eye") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) Text("Privacy") } } NavigationLink(destination: LazyClosureView(NotificationSettings())) { HStack{ Image(systemName: "text.bubble") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) Text("Notifications") } } NavigationLink(destination: LazyClosureView(AttachmentSettings())) { HStack { Image(systemName: "paperclip") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) Text("Attachments") } } Button(action: { UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) }, label: { HStack { Image(systemName: "gear") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) #if targetEnvironment(macCatalyst) Text("Open macOS settings") #else Text("Open iOS settings") #endif }.foregroundColor(Color(UIColor.label)) }) .buttonStyle(.borderless) } } .navigationBarTitle(Text("General Settings")) } } struct UserInterfaceSettings: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() var body: some View { Form { Section(header: Text("Previews")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.showGeoLocation) { Text("Show inline geo location") Text("Received geo locations are shared with Apple's Maps App.") } SettingsToggle(isOn: $generalSettingsDefaultsDB.showURLPreview) { Text("Show URL previews") Text("The operator of the webserver providing that URL may see your IP address.") } SettingsToggle(isOn: $generalSettingsDefaultsDB.useInlineSafari) { Text("Open URLs inline in Safari") Text("When disabled, URLs will opened in your default browser (that might not be Safari).") } } Section(header: Text("Input")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.showKeyboardOnChatOpen) { Text("Autofocus text input on chat open") Text("Will focus the textfield on macOS or iOS with hardware keyboard attached, will open the software keyboard otherwise.") } } Section(header: Text("Appearance")) { VStack(alignment: .leading, spacing: 0) { NavigationLink(destination: LazyClosureView(BackgroundSettings(contact:nil))) { Text("Chat background image").font(.body) } Text("Configure the background image displayed in open chats.") .foregroundColor(Color(UIColor.secondaryLabel)) .font(.footnote) .fixedSize(horizontal: false, vertical: true) } SettingsToggle(isOn: $generalSettingsDefaultsDB.showAdvancedUI) { Text("Show advanced options in UI") Text("Show power-user options in settings and other parts of the user interface.") } } } .navigationBarTitle(Text("User Interface"), displayMode: .inline) } } struct SecuritySettings: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() @State var autodeleteInterval: Int = 0 @State var autodeleteIntervalSelection: Int = 0 var autodeleteOptions = [ 0: NSLocalizedString("Off", comment:"Message autdelete time"), 30: NSLocalizedString("30 seconds", comment:"Message autdelete time"), 60: NSLocalizedString("1 minute", comment:"Message autdelete time"), 300: NSLocalizedString("5 minutes", comment:"Message autdelete time"), 900: NSLocalizedString("15 minutes", comment:"Message autdelete time"), 1800: NSLocalizedString("30 minutes", comment:"Message autdelete time"), 3600: NSLocalizedString("1 hour", comment:"Message autdelete time"), 43200: NSLocalizedString("12 hours", comment:"Message autdelete time"), 86400: NSLocalizedString("1 day", comment:"Message autdelete time"), 259200: NSLocalizedString("3 days", comment:"Message autdelete time"), 604800: NSLocalizedString("1 week", comment:"Message autdelete time"), 2419200: NSLocalizedString("4 weeks", comment:"Message autdelete time"), 5184000: NSLocalizedString("2 month", comment:"Message autdelete time"), //based on 30 days per month 7776000: NSLocalizedString("3 month", comment:"Message autdelete time"), //based on 30 days per month ] init() { _autodeleteInterval = State(wrappedValue:generalSettingsDefaultsDB.AutodeleteInterval) _autodeleteIntervalSelection = State(wrappedValue:generalSettingsDefaultsDB.AutodeleteInterval) autodeleteOptions[-1] = NSLocalizedString("Custom", comment:"Message autdelete time") //check if we have a custom value and change picker value accordingly if autodeleteOptions[autodeleteInterval] == nil { _autodeleteIntervalSelection = State(wrappedValue:-1) } } var body: some View { Form { Section(header: Text("Encryption")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.omemoDefaultOn) { Text("Enable encryption by default for new chats") Text("Every new contact will have encryption enabled, but already known contacts will preserve their encryption settings.") } if generalSettingsDefaultsDB.showAdvancedUI { SettingsToggle(isOn: $generalSettingsDefaultsDB.useDnssecForAllConnections) { Text("Use DNSSEC validation for all connections") Text( """ Use DNSSEC to validate all DNS query responses before connecting to the IP address designated \ in the DNS response.\n\ While being more secure, this can lead to connection problems in certain networks \ like hotel wifi, ugly mobile carriers etc. """ ) } } SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcAllowP2P) { Text("Calls: Allow P2P sessions") Text("Allow your device to establish a direct network connection to the remote party. This might leak your IP address to the caller/callee.") } } Section(header: Text("On this device")) { VStack(alignment: .leading, spacing: 0) { Picker(selection: $autodeleteIntervalSelection, label: Text("Autodelete all messages older than")) { ForEach(autodeleteOptions.keys.sorted(), id: \.self) { key in Text(autodeleteOptions[key]!).tag(key) } } //custom interval requested explicitly if autodeleteIntervalSelection == -1 { HStack { Text("Custom Time: ") Stepper(String(format:NSLocalizedString("%@ hours", comment:""), String(describing:(max(1, autodeleteInterval / 3600)).formatted())), value: Binding( get: { max(1, autodeleteInterval / 3600) /*clamp to 1 ... .max*/ }, set: { autodeleteInterval = $0 * 3600 } ), in: 1 ... .max) } } Text("Be warned: Message will only be deleted on incoming pushes or if you open the app! This is especially true for shorter time intervals!").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) Text("Also beware: You won't be able to load older history from your server, Monal will immediately delete it after fetching it!").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) } } } .navigationBarTitle(Text("Security"), displayMode: .inline) //save only when closing view to not delete messages while the user is selecting a (custom) value .onDisappear { if autodeleteIntervalSelection == -1 { //make sure our custom value is stored clamped, too autodeleteInterval = max(1, autodeleteInterval / 3600) } else { //copy over picker value if not set to custom autodeleteInterval = autodeleteIntervalSelection } generalSettingsDefaultsDB.AutodeleteInterval = autodeleteInterval } } } struct PrivacySettings: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() var body: some View { Form { PrivacySettingsSubview(onboardingPart:-1) } .navigationBarTitle(Text("Privacy"), displayMode: .inline) } } struct PrivacySettingsSubview: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() var onboardingPart: Int var body: some View { if onboardingPart == -1 || onboardingPart == 0 { Section(header: Text("Activity indications")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) { Text("Send message receipts") Text("Let your contacts know if you received a message.") } SettingsToggle(isOn: $generalSettingsDefaultsDB.sendDisplayedMarkers) { Text("Send read receipts") Text("Let your contacts know if you read a message.") } SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastChatState) { Text("Send typing notifications") Text("Let your contacts know if you are typing a message.") } SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastUserInteraction) { Text("Send last interaction time") Text("Let your contacts know when you last opened the app.") } } } if onboardingPart == -1 || onboardingPart == 1 { Section(header: Text("Interactions")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.allowNonRosterContacts) { Text("Accept incoming messages from strangers") Text("Allow contacts not in your contact list to contact you.") } SettingsToggle(isOn: Binding( get: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts && generalSettingsDefaultsDB.allowNonRosterContacts }, set: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts = $0 } )) { Text("Accept incoming calls from strangers") Text("Allow contacts not in your contact list to call you.") }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts) } } if onboardingPart == -1 || onboardingPart == 2 { Section(header: Text("Misc")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.allowVersionIQ) { Text("Publish version") #if IS_QUICKSY Text("Allow contacts in your contact list to query your Quicksy and iOS versions.") #else Text("Allow contacts in your contact list to query your Monal and iOS versions.") #endif } //the quicksy.im server always has a proper TURN server, no need for this setting there #if !IS_QUICKSY SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcUseFallbackTurn) { Text("Calls: Allow TURN fallback to Monal-Servers") Text("This will make calls possible even if your XMPP server does not provide a TURN server, but leaks your IP to Monal's servers if your XMPP server does not provide a TURN server.") } #endif } } } } struct NotificationSettings: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() @State private var pushPermissionEnabled = false private var pushNotEnabled: Bool { let xmppAccountInfo = MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp] var pushNotEnabled = false for account in xmppAccountInfo { pushNotEnabled = pushNotEnabled || !account.connectionProperties.pushEnabled } return pushNotEnabled } var body: some View { Form { Section(header: Text("Settings")) { Picker(selection: $generalSettingsDefaultsDB.notificationPrivacySetting, label: Text("Privacy")) { ForEach(NotificationPrivacySettingOption.allCases, id: \.self) { option in Text(getNotificationPrivacyOption(option)).tag(option.rawValue) } } .frame(height: 56, alignment: .trailing) } Section(header: Text("Debugging")) { NavigationLink(destination: LazyClosureView(NotificationDebugging())) { buildNotificationStateLabel(Text("Debug Notification Problems"), isWorking: !self.pushNotEnabled && self.pushPermissionEnabled) } } } .onAppear { UNUserNotificationCenter.current().getNotificationSettings { (settings) -> Void in self.pushPermissionEnabled = (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional); } } .navigationBarTitle(Text("Notifications"), displayMode: .inline) } } struct AttachmentSettings: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() var body: some View { Form { Section(header: Text("General File Transfer Settings")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.autodownloadFiletransfers) { Text("Auto-Download Media and Files") } SettingsToggle(isOn: $generalSettingsDefaultsDB.hardlinkFiletransfersIntoDocuments) { Text("Make transfered Media and Files accessible in Files App") } } Section(header: Text("Download Settings")) { Text("Adjust the maximum file size for auto-downloads over WiFi") .foregroundColor(.secondary) .font(.footnote) Slider( value: $generalSettingsDefaultsDB.autodownloadFiletransfersWifiMaxSize.bytecount(mappedTo: 1024*1024), in: 1.0...100.0, step: 1.0, minimumValueLabel: Text("1 MiB"), maximumValueLabel: Text("100 MiB"), label: { Text("Load over wifi") } ) Text("Load over WiFi up to: \(String(describing:UInt(generalSettingsDefaultsDB.autodownloadFiletransfersWifiMaxSize/(1024*1024)))) MiB") } Section { Text("Adjust the maximum file size for auto-downloads over cellular network") .foregroundColor(.secondary) .font(.footnote) Slider( value: $generalSettingsDefaultsDB.autodownloadFiletransfersMobileMaxSize.bytecount(mappedTo: 1024*1024), in: 0.0...100.0, step: 1.0, minimumValueLabel: Text("1 MiB"), maximumValueLabel: Text("100 MiB"), label: { Text("Load over Cellular") } ) Text("Load over cellular up to: \(String(describing:UInt(generalSettingsDefaultsDB.autodownloadFiletransfersMobileMaxSize/(1024*1024)))) MiB") } Section(header: Text("Upload Settings")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.uploadImagesOriginal) { Text("Upload Original Images") } if !generalSettingsDefaultsDB.uploadImagesOriginal { Text("Adjust the quality of images uploaded") .foregroundColor(.secondary) .font(.footnote) Slider( value: $generalSettingsDefaultsDB.imageUploadQuality, in: 0.33...1.0, step: 0.01, minimumValueLabel: Text("33%"), maximumValueLabel: Text("100%"), label: { Text("Upload Settings") } ) Text("Image Upload JPEG-Quality: \(String(format: "%.0f%%", generalSettingsDefaultsDB.imageUploadQuality*100))") } } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { GeneralSettings() } }