diff --git a/Nio.xcodeproj/project.pbxproj b/Nio.xcodeproj/project.pbxproj index e5ed49eb..d6fe11e9 100644 --- a/Nio.xcodeproj/project.pbxproj +++ b/Nio.xcodeproj/project.pbxproj @@ -56,8 +56,12 @@ 39DD77BB2470C49D00A29DEE /* Six Colors Light@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 39DD77B92470C49D00A29DEE /* Six Colors Light@2x.png */; }; 39E5032324EAFA8700FED642 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 39E5032224EAFA8700FED642 /* Introspect */; }; 4B0A2E47245E2EF800A79443 /* MultilineTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0A2E46245E2EF800A79443 /* MultilineTextField.swift */; }; + 4B0B26C826050818009E2D3A /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0B26C726050818009E2D3A /* Contacts.swift */; }; + 4B0B26C926050818009E2D3A /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0B26C726050818009E2D3A /* Contacts.swift */; }; 4B29F5B52466EC240084043B /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B29F5B42466EC240084043B /* ImagePicker.swift */; }; 4B693515249BBBFE0029D549 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BFEFD7E246F414D00CCF4A0 /* ShareViewController.swift */; }; + 4B8225A426091A5F0013E733 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B8225A626091A5F0013E733 /* InfoPlist.strings */; }; + 4B9AB8C32608D08A005E1092 /* IdentityServerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9AB8C22608D08A005E1092 /* IdentityServerSettings.swift */; }; 4BD867272460ADEF0014E3D6 /* NewConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD867262460ADEF0014E3D6 /* NewConversationView.swift */; }; 4BFEFD86246F414D00CCF4A0 /* NioShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4BFEFD7C246F414D00CCF4A0 /* NioShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4BFEFD8C246F458000CCF4A0 /* GetURL.js in Resources */ = {isa = PBXBuildFile; fileRef = 4BFEFD8B246F458000CCF4A0 /* GetURL.js */; }; @@ -348,7 +352,11 @@ 4B058B5524573A570059BC75 /* EditEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditEvent.swift; sourceTree = ""; }; 4B08F0A82495202A008E4286 /* LocalConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = LocalConfig.xcconfig; sourceTree = ""; }; 4B0A2E46245E2EF800A79443 /* MultilineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextField.swift; sourceTree = ""; }; + 4B0B26C726050818009E2D3A /* Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contacts.swift; sourceTree = ""; }; 4B29F5B42466EC240084043B /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; + 4B8225A526091A5F0013E733 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; + 4B8225AD26091A650013E733 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; + 4B9AB8C22608D08A005E1092 /* IdentityServerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityServerSettings.swift; sourceTree = ""; }; 4BD3D29224951E3B0031846F /* GlobalConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = GlobalConfig.xcconfig; sourceTree = ""; }; 4BD3DCA32482FF08009B5048 /* AccountStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStore.swift; sourceTree = ""; }; 4BD867262460ADEF0014E3D6 /* NewConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationView.swift; sourceTree = ""; }; @@ -473,6 +481,7 @@ children = ( 3902B8A22395935600698B87 /* SettingsView.swift */, 39DD77AE247006E300A29DEE /* AppIcon.swift */, + 4B9AB8C22608D08A005E1092 /* IdentityServerSettings.swift */, ); path = Settings; sourceTree = ""; @@ -661,7 +670,6 @@ 39D166C42385C7F6006DD257 /* Extensions */, 3921175D2442563A00892B00 /* Generated */, 39C931E22384328B004449E1 /* Assets.xcassets */, - 39C931EA2384328B004449E1 /* Info.plist */, 390D63BA246F4BEE00B8F640 /* Resources */, 39C931F3238449C2004449E1 /* Supporting Files */, 39C931E42384328B004449E1 /* Preview Content */, @@ -680,10 +688,12 @@ 39C931F3238449C2004449E1 /* Supporting Files */ = { isa = PBXGroup; children = ( + 39C931EA2384328B004449E1 /* Info.plist */, 39C931F623846B2D004449E1 /* .swiftlint.yml */, 3921175B244255D600892B00 /* swiftgen.yml */, 39C931E72384328B004449E1 /* LaunchScreen.storyboard */, 392117592442556700892B00 /* Localizable.strings */, + 4B8225A626091A5F0013E733 /* InfoPlist.strings */, ); path = "Supporting Files"; sourceTree = ""; @@ -809,6 +819,7 @@ 393411C823904428003B49B8 /* MXEvent+Extensions.swift */, 4BEB8C03250403D200E90699 /* UserDefaults.swift */, E897AA3325F2716F00D11427 /* UXKit.swift */, + 4B0B26C726050818009E2D3A /* Contacts.swift */, ); path = Extensions; sourceTree = ""; @@ -1143,6 +1154,7 @@ 392117572442556700892B00 /* Localizable.strings in Resources */, 39C931F723846B2D004449E1 /* .swiftlint.yml in Resources */, 39DD77B62470C38300A29DEE /* Six Colors Dark@3x.png in Resources */, + 4B8225A426091A5F0013E733 /* InfoPlist.strings in Resources */, 39C931E92384328B004449E1 /* LaunchScreen.storyboard in Resources */, 39C931E62384328B004449E1 /* Preview Assets.xcassets in Resources */, 39DD77BA2470C49D00A29DEE /* Six Colors Light@3x.png in Resources */, @@ -1284,6 +1296,7 @@ 3923898F2388707E00B2E1DF /* RoomListItemView.swift in Sources */, 4B29F5B52466EC240084043B /* ImagePicker.swift in Sources */, CAF2AE8B2458EF0900D84133 /* MarkdownText.swift in Sources */, + 4B9AB8C32608D08A005E1092 /* IdentityServerSettings.swift in Sources */, 395E06FD25DDC1790059F6AD /* SFSymbol.swift in Sources */, 39DD77AF247006E300A29DEE /* AppIcon.swift in Sources */, CAFCB323245F6E6700869320 /* NSAttributedString+Extensions.swift in Sources */, @@ -1362,6 +1375,7 @@ E8B4725925F26D5A00ACEFCB /* NIORoomSummary.swift in Sources */, E8B4725725F26D5A00ACEFCB /* EditEvent.swift in Sources */, E8B4726125F26D5A00ACEFCB /* AccountStore.swift in Sources */, + 4B0B26C826050818009E2D3A /* Contacts.swift in Sources */, E8B4725825F26D5A00ACEFCB /* CustomEvent.swift in Sources */, E8B4725F25F26D5A00ACEFCB /* Configuration.swift in Sources */, E8B4725D25F26D5A00ACEFCB /* EventCollection.swift in Sources */, @@ -1445,6 +1459,7 @@ E897AA1B25F2707C00D11427 /* Configuration.swift in Sources */, E897AA1C25F2707C00D11427 /* CustomEvent.swift in Sources */, E897AA2325F2707C00D11427 /* Reaction.swift in Sources */, + 4B0B26C926050818009E2D3A /* Contacts.swift in Sources */, E897AA1925F2707C00D11427 /* EditEvent.swift in Sources */, E897AA1F25F2707C00D11427 /* MX+Identifiable.swift in Sources */, E897AA1D25F2707C00D11427 /* MXCredentials+Keychain.swift in Sources */, @@ -1539,6 +1554,15 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; + 4B8225A626091A5F0013E733 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 4B8225A526091A5F0013E733 /* en */, + 4B8225AD26091A650013E733 /* nl */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -1711,7 +1735,7 @@ CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_ASSET_PATHS = "\"Nio/Preview Content\""; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = Nio/Info.plist; + INFOPLIST_FILE = "Nio/Supporting Files/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1735,7 +1759,7 @@ DEVELOPMENT_ASSET_PATHS = "\"Nio/Preview Content\""; DEVELOPMENT_TEAM = HU85FER47E; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = Nio/Info.plist; + INFOPLIST_FILE = "Nio/Supporting Files/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Nio/Generated/Strings.swift b/Nio/Generated/Strings.swift index 32ee5f0b..921dcc15 100644 --- a/Nio/Generated/Strings.swift +++ b/Nio/Generated/Strings.swift @@ -170,6 +170,8 @@ internal enum L10n { internal static let alertFailed = L10n.tr("Localizable", "new-conversation.alert-failed") /// Cancel internal static let cancel = L10n.tr("Localizable", "new-conversation.cancel") + /// Contacts on Matrix + internal static let contactsMatrix = L10n.tr("Localizable", "new-conversation.contacts-matrix") /// Start Chat internal static let createRoom = L10n.tr("Localizable", "new-conversation.create-room") /// Done @@ -188,6 +190,8 @@ internal enum L10n { internal static let titleRoom = L10n.tr("Localizable", "new-conversation.title-room") /// Matrix ID internal static let usernamePlaceholder = L10n.tr("Localizable", "new-conversation.username-placeholder") + /// Matrix ID, Phone, or Email + internal static let usernamePlaceholderExtended = L10n.tr("Localizable", "new-conversation.username-placeholder-extended") } internal enum ReactionPicker { @@ -295,6 +299,49 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "settings.title") } + internal enum SettingsIdentityServer { + /// Closed Federation + internal static let closedFederation = L10n.tr("Localizable", "settings-identity-server.closed-federation") + /// At the moment, the identity servers are in a closed federation configuration. This means that there are only two identity servers (matrix.org, vector.im) and all data uploaded to one is copied to the other. + internal static let closedFederationText = L10n.tr("Localizable", "settings-identity-server.closed-federation-text") + /// Sync Contacts + internal static let contactSync = L10n.tr("Localizable", "settings-identity-server.contact-sync") + /// Continue + internal static let `continue` = L10n.tr("Localizable", "settings-identity-server.continue") + /// Data + internal static let data = L10n.tr("Localizable", "settings-identity-server.data") + /// Data & Privacy + internal static let dataPrivacy = L10n.tr("Localizable", "settings-identity-server.data-privacy") + /// By using the identity server, your email address and phone number will be sent to the identity server and will be linked to your Matrix ID (%@). + internal static func dataText(_ p1: Any) -> String { + return L10n.tr("Localizable", "settings-identity-server.data-text", String(describing: p1)) + } + /// Learn More + internal static let learnMore = L10n.tr("Localizable", "settings-identity-server.learn-more") + /// Match + internal static let match = L10n.tr("Localizable", "settings-identity-server.match") + /// Any user can retrieve your Matrix ID by entering your email address or phone number, but cannot find your email address or phone number by entering your Matrix ID. + internal static let matchText = L10n.tr("Localizable", "settings-identity-server.match-text") + /// (Optional) Contact Sync + internal static let optionalContactSync = L10n.tr("Localizable", "settings-identity-server.optional-contact-sync") + /// When activating contact syncronization, Nio will periodically send the email addresses and phone numbers of all your contacts to the identity server to see if that contact has a linked Matrix ID. This contact information is never stored or shared with other parties by Nio. + internal static let optionalContactSyncText = L10n.tr("Localizable", "settings-identity-server.optional-contact-sync-text") + /// Please go to Settings and turn on the permissions. + internal static let permissionAlertBody = L10n.tr("Localizable", "settings-identity-server.permission-alert-body") + /// No permissions + internal static let permissionAlertTitle = L10n.tr("Localizable", "settings-identity-server.permission-alert-title") + /// Cancel + internal static let permissionCancelButton = L10n.tr("Localizable", "settings-identity-server.permission-cancel-button") + /// Settings + internal static let permissionSettingsButton = L10n.tr("Localizable", "settings-identity-server.permission-settings-button") + /// Identity Server + internal static let title = L10n.tr("Localizable", "settings-identity-server.title") + /// Enable Identity Server + internal static let toggle = L10n.tr("Localizable", "settings-identity-server.toggle") + /// Identity URL + internal static let url = L10n.tr("Localizable", "settings-identity-server.url") + } + internal enum TypingIndicator { /// Several people are typing internal static let many = L10n.tr("Localizable", "typing-indicator.many") diff --git a/Nio/NewConversation/NewConversationView.swift b/Nio/NewConversation/NewConversationView.swift index 9e4cc291..b0151cd9 100644 --- a/Nio/NewConversation/NewConversationView.swift +++ b/Nio/NewConversation/NewConversationView.swift @@ -3,21 +3,50 @@ import MatrixSDK import NioKit +enum UserStatus { + case unknown // Validity of user is still unknown. + case notValid // User is not valid. + case valid // User is valid. + case retrieving // Validity of user is being retrieved from identity server. +} + struct NewConversationContainerView: View { @EnvironmentObject private var store: AccountStore @Binding var createdRoomId: ObjectIdentifier? + @AppStorage("matrixUsers") private var matrixUsersJSON: String = "" var body: some View { - NewConversationView(store: store, createdRoomId: $createdRoomId) + NewConversationView( + store: store, + createdRoomId: $createdRoomId, + matrixUsers: { () -> [MatrixUser] in + do { + var matrixArray: [MatrixUser] = try JSONDecoder().decode( + [MatrixUser].self, from: matrixUsersJSON.data(using: .utf8) ?? Data() + ) + matrixArray.sort(by: { (lhs, rhs) in return lhs.getFirstName() > rhs.getFirstName() }) + return matrixArray + } catch { + return [] + } + }() + ) } } private struct NewConversationView: View { @Environment(\.presentationMode) private var presentationMode + @AppStorage("identityServerBool") private var identityServerBool: Bool = false let store: AccountStore? @State private var users = [""] + @State private var usersVerified: [UserStatus] = [UserStatus.unknown] + + @State private var numCalls: Int = 0 + + @State private var isRetrieving = false + #if !os(macOS) @State private var editMode = EditMode.inactive #endif @@ -28,6 +57,8 @@ private struct NewConversationView: View { @Binding var createdRoomId: ObjectIdentifier? @State private var errorMessage: String? + let matrixUsers: [MatrixUser] + private var usersFooter: some View { Text("\(L10n.NewConversation.forExample) \(store?.session?.myUserId ?? "@username:server.org")") } @@ -37,11 +68,36 @@ private struct NewConversationView: View { Section(footer: usersFooter) { ForEach(0.. 1)) + .disabled( + users.contains("") + || (roomName.isEmpty && users.count > 1) + || usersVerified.contains(UserStatus.notValid) + || usersVerified.contains(UserStatus.retrieving) + || usersVerified.contains(UserStatus.unknown) + ) #endif Spacer() ProgressView() .opacity(isWaiting ? 1.0 : 0.0) + } } .alert(item: $errorMessage) { errorMessage in Alert(title: Text(verbatim: L10n.NewConversation.alertFailed), message: Text(errorMessage)) } + + Section(header: Text(L10n.NewConversation.contactsMatrix)) { + ForEach(0..= 6 { + numCalls += 1 + let mx3pids: [MX3PID] = [ + // Look for email occurrences on the identity server. + MX3PID.init(medium: MX3PID.Medium.email, address: user), + // Look for phone number occurrences on the identity server. + MX3PID.init( + medium: MX3PID.Medium.msisdn, + address: user.replacingOccurrences(of: "+", with: "").replacingOccurrences(of: " ", with: "") + ), + ] + store?.identityService?.lookup3PIDs(mx3pids) { response in + numCalls -= 1 + // Check if it is the last identity request. + if numCalls == 0 { + if response.value?.count ?? 0 > 0 { + response.value?.forEach({ (responseItem: (key: MX3PID, value: String)) in + users[userIndex] = responseItem.value + usersVerified[userIndex] = UserStatus.valid + }) + } else { + // In case the request did not come back in the correct order send the last value again. + if !recheck { + findUser(userIndex: userIndex, recheck: true) + } else { + usersVerified[userIndex] = UserStatus.notValid + } + } + } + } + } else if result != nil { + usersVerified[userIndex] = UserStatus.valid + } else { + usersVerified[userIndex] = UserStatus.notValid + } + } + private func createRoom() { isWaiting = true @@ -180,7 +330,7 @@ private struct NewConversationView: View { struct NewConversationView_Previews: PreviewProvider { static var previews: some View { - NewConversationView(store: nil, createdRoomId: .constant(nil)) + NewConversationView(store: nil, createdRoomId: .constant(nil), matrixUsers: []) .preferredColorScheme(.light) } } diff --git a/Nio/Settings/IdentityServerSettings.swift b/Nio/Settings/IdentityServerSettings.swift new file mode 100644 index 00000000..c9ce7feb --- /dev/null +++ b/Nio/Settings/IdentityServerSettings.swift @@ -0,0 +1,247 @@ +import SwiftUI +import MatrixSDK + +import NioKit + +struct IdentityServerSettingsContainerView: View { + @EnvironmentObject var store: AccountStore + + var body: some View { + IdentityServerSettingsView() + } +} + +struct ButtonModifier: ViewModifier { + @AppStorage("accentColor") private var accentColor: Color = .purple + + func body(content: Content) -> some View { + content + .foregroundColor(.white) + .font(.headline) + .padding() + .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) + .background(RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(accentColor)) + .padding(.bottom) + } +} + +extension View { + func customButton() -> ModifiedContent { + return modifier(ButtonModifier()) + } +} + +extension Text { + func customTitleText() -> Text { + self + .fontWeight(.black) + .font(.system(size: 36)) + } +} + +struct InformationDetailView: View { + @AppStorage("accentColor") private var accentColor: Color = .purple + + var title: String = "" + var subTitle: String = "" + var imageName: String = "" + + var body: some View { + HStack(alignment: .center) { + Image(systemName: imageName) + .font(.largeTitle) + .foregroundColor(accentColor) + .padding() + .accessibility(hidden: true) + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .leading) { + Text(title) + .font(.headline) + .foregroundColor(.primary) + .accessibility(addTraits: .isHeader) + .textCase(.none) + + Text(subTitle) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textCase(.none) + } + } + .padding(.top) + } +} + +struct InformationContainerView: View { + @EnvironmentObject private var store: AccountStore + + var body: some View { + VStack(alignment: .leading) { + InformationDetailView( + title: L10n.SettingsIdentityServer.data, + subTitle: L10n.SettingsIdentityServer.dataText(store.session?.myUserId ?? "@username:server.org"), + imageName: "arrow.up.doc" + ) + + InformationDetailView( + title: L10n.SettingsIdentityServer.match, + subTitle: L10n.SettingsIdentityServer.matchText, + imageName: "magnifyingglass" + ) + + InformationDetailView(title: L10n.SettingsIdentityServer.closedFederation, + subTitle: L10n.SettingsIdentityServer.closedFederationText, + imageName: "globe" + ) + + InformationDetailView(title: L10n.SettingsIdentityServer.optionalContactSync, + subTitle: L10n.SettingsIdentityServer.optionalContactSyncText, + imageName: "person") + } + .padding(.horizontal) + } +} + +struct TitleView: View { + @AppStorage("accentColor") private var accentColor: Color = .purple + + var body: some View { + VStack { + Image(systemName: "person.3") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 180, alignment: .center) + .accessibility(hidden: true) + .foregroundColor(accentColor) + + Text(L10n.SettingsIdentityServer.dataPrivacy) + .customTitleText() + + Text(L10n.SettingsIdentityServer.title) + .customTitleText() + .foregroundColor(accentColor) + } + } +} + +struct IdentityServerInfoView: View { + @AppStorage("accentColor") private var accentColor: Color = .purple + + @Environment(\.presentationMode) var presentationMode + + var body: some View { + ScrollView { + VStack(alignment: .center) { + + Spacer() + + TitleView() + + InformationContainerView() + + Spacer(minLength: 30) + + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Text(L10n.SettingsIdentityServer.continue) + .customButton() + } + .padding(.horizontal) + + Spacer() + + Link( + L10n.SettingsIdentityServer.learnMore, + destination: URL(string: "https://matrix.org/legal/identity-server-privacy-notice-1")! + ) + .foregroundColor(accentColor) + } + } + .padding(.vertical) + } +} + +struct IdentityServerSettingsView: View { + @AppStorage("identityServerBool") private var identityServerBool: Bool = false + @AppStorage("identityServer") private var identityServer: String = "https://vector.im" + @AppStorage("locSyncContacts") private var locSyncContacts: Bool = false + @AppStorage("matrixUsers") private var matrixUsersJSON: String = "" + + @EnvironmentObject var store: AccountStore + + @State private var showModal = false + @State private var showContactStateWarning = false + + var identityServerInfo: some View { + Button(action: { + showModal.toggle() + }) { + HStack { + Text(L10n.SettingsIdentityServer.title) + Image(systemName: "info.circle") + } + } + .fullScreenCover(isPresented: $showModal, content: IdentityServerInfoView.init) + } + + var body: some View { + Section(header: identityServerInfo) { + Toggle( + L10n.SettingsIdentityServer.toggle, + isOn: $identityServerBool + ).onChange( + of: identityServerBool, + perform: syncIdentityServer(isSync:) + ) + if identityServerBool { + TextField(L10n.SettingsIdentityServer.url, text: $identityServer) + Toggle( + L10n.SettingsIdentityServer.contactSync, + isOn: Binding( + get: { Contacts.hasPermission() && locSyncContacts }, + set: { status in + locSyncContacts = status + if locSyncContacts && !Contacts.hasPermission() { + self.showContactStateWarning.toggle() + } + } + ) + ) + .onChange(of: locSyncContacts, perform: syncContacts(isSync:)) + .alert(isPresented: $showContactStateWarning) { + Alert( + title: Text(L10n.SettingsIdentityServer.permissionAlertTitle), + message: Text(L10n.SettingsIdentityServer.permissionAlertBody), + primaryButton: .cancel(Text(L10n.SettingsIdentityServer.permissionCancelButton)), + secondaryButton: .default(Text(L10n.SettingsIdentityServer.permissionSettingsButton), action: { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + })) + } + Button(action: { + self.matrixUsersJSON = "" + self.syncContacts(isSync: locSyncContacts) + }, label: { + Text(verbatim: "Force Sync") + }) + } + } + } + + private func syncIdentityServer(isSync: Bool) { + if isSync { + store.setIdentityService() + } else { + locSyncContacts = false + } + } + + private func syncContacts(isSync: Bool) { + if isSync { + store.updateMatrixContacts() + } + } +} diff --git a/Nio/Settings/SettingsView.swift b/Nio/Settings/SettingsView.swift index 636c51d1..19e6d344 100644 --- a/Nio/Settings/SettingsView.swift +++ b/Nio/Settings/SettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import MatrixSDK import NioKit struct SettingsContainerView: View { @@ -49,6 +50,7 @@ private struct SettingsView: View { @AppStorage("accentColor") private var accentColor: Color = .purple @StateObject private var appIconTitle = AppIconTitle() let logoutAction: () -> Void + @EnvironmentObject var store: AccountStore @Environment(\.presentationMode) private var presentationMode @@ -73,6 +75,8 @@ private struct SettingsView: View { } } + IdentityServerSettingsContainerView() + Section { Button(action: self.logoutAction) { Text(verbatim: L10n.Settings.logOut) diff --git a/Nio/Info.plist b/Nio/Supporting Files/Info.plist similarity index 94% rename from Nio/Info.plist rename to Nio/Supporting Files/Info.plist index b22410e7..35598db3 100644 --- a/Nio/Info.plist +++ b/Nio/Supporting Files/Info.plist @@ -83,6 +83,8 @@ DevelopmentTeam $(DEVELOPMENT_TEAM) + NSContactsUsageDescription + The contact information is used to find a Matrix ID on the Identity Server for all your contacts. UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait diff --git a/Nio/Supporting Files/en.lproj/InfoPlist.strings b/Nio/Supporting Files/en.lproj/InfoPlist.strings new file mode 100644 index 00000000..6f2d37e9 --- /dev/null +++ b/Nio/Supporting Files/en.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSContactsUsageDescription" = "The contact information is used to find a Matrix ID on the Identity Server for all your contacts."; diff --git a/Nio/Supporting Files/en.lproj/Localizable.strings b/Nio/Supporting Files/en.lproj/Localizable.strings index 7779175a..974a73f8 100644 --- a/Nio/Supporting Files/en.lproj/Localizable.strings +++ b/Nio/Supporting Files/en.lproj/Localizable.strings @@ -78,9 +78,29 @@ "settings.app-icon" = "App Icon"; "settings.log-out" = "Log Out"; "settings.dismiss" = "Done"; +"settings-identity-server.permission-alert-title" = "No permissions"; +"settings-identity-server.permission-alert-body" = "Please go to Settings and turn on the permissions."; +"settings-identity-server.permission-settings-button" = "Settings"; +"settings-identity-server.permission-cancel-button" = "Cancel"; +"settings-identity-server.title" = "Identity Server"; +"settings-identity-server.toggle" = "Enable Identity Server"; +"settings-identity-server.url" = "Identity URL"; +"settings-identity-server.contact-sync" = "Sync Contacts"; +"settings-identity-server.continue" = "Continue"; +"settings-identity-server.learn-more" = "Learn More"; +"settings-identity-server.data-privacy" = "Data & Privacy"; +"settings-identity-server.data" = "Data"; +"settings-identity-server.data-text" = "By using the identity server, your email address and phone number will be sent to the identity server and will be linked to your Matrix ID (%@)."; +"settings-identity-server.match" = "Match"; +"settings-identity-server.match-text" = "Any user can retrieve your Matrix ID by entering your email address or phone number, but cannot find your email address or phone number by entering your Matrix ID."; +"settings-identity-server.closed-federation" = "Closed Federation"; +"settings-identity-server.closed-federation-text" = "At the moment, the identity servers are in a closed federation configuration. This means that there are only two identity servers (matrix.org, vector.im) and all data uploaded to one is copied to the other."; +"settings-identity-server.optional-contact-sync" = "(Optional) Contact Sync"; +"settings-identity-server.optional-contact-sync-text" = "When activating contact syncronization, Nio will periodically send the email addresses and phone numbers of all your contacts to the identity server to see if that contact has a linked Matrix ID. This contact information is never stored or shared with other parties by Nio."; "new-conversation.title-chat" = "New Chat"; "new-conversation.title-room" = "New Room"; "new-conversation.username-placeholder" = "Matrix ID"; +"new-conversation.username-placeholder-extended" = "Matrix ID, Phone, or Email"; "new-conversation.for-example" = "For example"; "new-conversation.room-name" = "Room Name"; "new-conversation.public-room" = "Public Room"; @@ -89,3 +109,4 @@ "new-conversation.cancel" = "Cancel"; "new-conversation.edit" = "Edit"; "new-conversation.done" = "Done"; +"new-conversation.contacts-matrix" = "Contacts on Matrix"; diff --git a/Nio/Supporting Files/nl.lproj/InfoPlist.strings b/Nio/Supporting Files/nl.lproj/InfoPlist.strings new file mode 100644 index 00000000..05291211 --- /dev/null +++ b/Nio/Supporting Files/nl.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSContactsUsageDescription" = "The contact informatie wordt gebruikt om een Matrix ID op te halen bij de Identity Server voor al uw contacten."; diff --git a/NioKit/Extensions/Contacts.swift b/NioKit/Extensions/Contacts.swift new file mode 100644 index 00000000..8d894803 --- /dev/null +++ b/NioKit/Extensions/Contacts.swift @@ -0,0 +1,65 @@ +// +// Contacts.swift +// Nio +// +// Created by Stefan Hofman on 19/03/2021. +// Copyright © 2021 Kilian Koeltzsch. All rights reserved. +// + +import Foundation +import Contacts + +public struct MatrixUser: Codable { + let firstName: String + let lastName: String + let matrixID: String + + public init(firstName: String, lastName: String, matrixID: String) { + self.firstName = firstName + self.lastName = lastName + self.matrixID = matrixID + } + public func getFirstName() -> String { + return self.firstName + } + + public func getLastName() -> String { + return self.lastName + } + + public func getMatrixID() -> String { + return self.matrixID + } +} + +public class Contacts { + + public static func hasPermission() -> Bool { + switch CNContactStore.authorizationStatus(for: CNEntityType.contacts) { + case .authorized: + return true + case .notDetermined: + return true + default: + return false + } + } + + public static func getAllContacts() -> [CNContact] { + var contacts = [CNContact]() + let store = CNContactStore() + do { + let keys = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactEmailAddressesKey] as [CNKeyDescriptor] + let request = CNContactFetchRequest(keysToFetch: keys) + try store.enumerateContacts(with: request) { (contact, _) in + // Array containing all unified contacts from everywhere + if contact.emailAddresses.count > 0 { + contacts.append(contact) + } + } + return contacts + } catch { + return [] + } + } +} diff --git a/NioKit/Session/AccountStore.swift b/NioKit/Session/AccountStore.swift index 2facdeed..ba08105c 100644 --- a/NioKit/Session/AccountStore.swift +++ b/NioKit/Session/AccountStore.swift @@ -2,6 +2,7 @@ import Foundation import Combine import MatrixSDK import KeychainAccess +import SwiftUI public enum LoginState { case loggedOut @@ -10,10 +11,18 @@ public enum LoginState { case loggedIn(userId: String) } +@available(iOS 14.0, *) public class AccountStore: ObservableObject { + @AppStorage("identityServer") private var identityServer: String = "https://vector.im" + @AppStorage("identityServerBool") private var identityServerBool: Bool = false + @AppStorage("matrixUsers") private var matrixUsersJSON: String = "" + @AppStorage("locSyncContacts") private var locSyncContacts: Bool = false + public var client: MXRestClient? public var session: MXSession? + public var identityService: MXIdentityService? + var fileStore: MXFileStore? var credentials: MXCredentials? @@ -120,6 +129,14 @@ public class AccountStore: ObservableObject { self.session = MXSession(matrixRestClient: self.client!) self.fileStore = MXFileStore() + if self.identityServerBool { + self.setIdentityService() + } + + if self.locSyncContacts && Contacts.hasPermission(){ + self.updateMatrixContacts() + } + self.session!.setStore(fileStore!) { response in switch response { case .failure(let error): @@ -200,4 +217,50 @@ public class AccountStore: ObservableObject { self.objectWillChange.send() } } + + public func setIdentityService() { + self.identityService = MXIdentityService.init( + identityServer: URL(string: identityServer)!, + accessToken: nil, + homeserverRestClient: self.client! + ) + } + + public func updateMatrixContacts() { + var matrixUsers: [MatrixUser] = { () -> [MatrixUser] in + do { + return try JSONDecoder().decode( + [MatrixUser].self, from: matrixUsersJSON.data(using: .utf8) ?? Data() + ) + } catch { + return [] + } + }() + let contacts = Contacts.getAllContacts() + contacts.forEach { (contact) in + var mx3pids: [MX3PID] = [] + contact.emailAddresses.forEach { (email) in + mx3pids.append(MX3PID.init(medium: MX3PID.Medium.email, address: email.value as String)) + } + self.identityService?.lookup3PIDs(mx3pids) { [self] response in + response.value?.forEach({ (responseItem: (key: MX3PID, value: String)) in + do { + if (!matrixUsers.contains(where: { user in + return user.matrixID == responseItem.value + })) { + matrixUsers.append( + MatrixUser( + firstName: contact.givenName, + lastName: contact.familyName, + matrixID: responseItem.value + ) + ) + let jsonData = try JSONEncoder().encode(matrixUsers) + self.matrixUsersJSON = String(data: jsonData, encoding: .utf8)! + } + } catch { print(error) } + }) + } + } + } } diff --git a/NioShareExtension/ShareContentView.swift b/NioShareExtension/ShareContentView.swift index 228fe5f5..000152b6 100644 --- a/NioShareExtension/ShareContentView.swift +++ b/NioShareExtension/ShareContentView.swift @@ -2,6 +2,7 @@ import SwiftUI import NioKit import UIKit +@available(iOSApplicationExtension 14.0, *) struct ShareContentView: View { @State var parentView: ShareNavigationController @State var showConfirm = false diff --git a/NioShareExtension/ShareViewController.swift b/NioShareExtension/ShareViewController.swift index 4490bbb3..f8939e79 100644 --- a/NioShareExtension/ShareViewController.swift +++ b/NioShareExtension/ShareViewController.swift @@ -4,6 +4,8 @@ import MobileCoreServices import SwiftUI import NioKit +@available(macCatalystApplicationExtension 14.0, *) +@available(iOSApplicationExtension 14.0, *) @objc(ShareNavigationController) class ShareNavigationController: UIViewController { diff --git a/swiftgen.yml b/swiftgen.yml index 54341918..e2433900 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -1,6 +1,6 @@ strings: inputs: "Nio/Supporting Files/en.lproj" - filter: .+\.strings$ + filter: (\b(Localizable)\b)\.strings$ outputs: - templateName: structured-swift4 output: Nio/Generated/Strings.swift