From c609303348784a23a134424faaad1e2813b247ab Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 13 Oct 2023 19:19:00 +0400 Subject: [PATCH] ios: connect plan (#3205) * ios: connect plan * improvements * wording * fixes * rework to use dismissAllSheets with callback * rework * update texts * Update apps/ios/Shared/Views/NewChat/NewChatButton.swift * Update apps/ios/Shared/Views/NewChat/NewChatButton.swift --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/ContentView.swift | 37 +- apps/ios/Shared/Model/ChatModel.swift | 10 + apps/ios/Shared/Model/SimpleXAPI.swift | 21 +- .../Chat/Group/GroupMemberInfoView.swift | 35 +- .../Shared/Views/NewChat/NewChatButton.swift | 346 ++++++++++++++++-- .../Views/NewChat/PasteToConnectView.swift | 18 +- .../Views/NewChat/ScanToConnectView.swift | 18 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 30 +- apps/ios/SimpleXChat/APITypes.swift | 34 ++ 9 files changed, 437 insertions(+), 112 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 3dbcf47004..04eadac2c1 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -31,11 +31,11 @@ struct ContentView: View { @State private var chatListActionSheet: ChatListActionSheet? = nil private enum ChatListActionSheet: Identifiable { - case connectViaUrl(action: ConnReqType, link: String) + case planAndConnectSheet(sheet: PlanAndConnectActionSheet) var id: String { switch self { - case let .connectViaUrl(_, link): return "connectViaUrl \(link)" + case let .planAndConnectSheet(sheet): return sheet.id } } } @@ -93,7 +93,7 @@ struct ContentView: View { mainView() .actionSheet(item: $chatListActionSheet) { sheet in switch sheet { - case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link) + case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) } } } else { @@ -290,12 +290,19 @@ struct ContentView: View { if let url = m.appOpenUrl { m.appOpenUrl = nil var path = url.path - logger.debug("ContentView.connectViaUrl path: \(path)") if (path == "/contact" || path == "/invitation") { path.removeFirst() - let action: ConnReqType = path == "contact" ? .contact : .invitation - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - chatListActionSheet = .connectViaUrl(action: action, link: link) + // TODO normalize in backend; revert + // let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + var link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + link = link.starts(with: "simplex:/") ? link.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") : link + planAndConnect( + link, + showAlert: showPlanAndConnectAlert, + showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) }, + dismiss: false, + incognito: nil + ) } else { AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) } @@ -303,20 +310,8 @@ struct ContentView: View { } } - private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet { - let title: LocalizedStringKey - switch action { - case .contact: title = "Connect via contact link" - case .invitation: title = "Connect via one-time link" - } - return ActionSheet( - title: Text(title), - buttons: [ - .default(Text("Use current profile")) { connectViaLink(link, incognito: false) }, - .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) }, - .cancel() - ] - ) + private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { + AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ec1a567420..b1a83dd5e6 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -152,6 +152,16 @@ final class ChatModel: ObservableObject { } } + func getGroupChat(_ groupId: Int64) -> Chat? { + chats.first { chat in + if case let .group(groupInfo) = chat.chatInfo { + return groupInfo.groupId == groupId + } else { + return false + } + } + } + private func getChatIndex(_ id: String) -> Int? { chats.firstIndex(where: { $0.id == id }) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 18e7f7efbb..a1c8cee774 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -586,6 +586,15 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P throw r } +func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { + logger.error("apiConnectPlan connReq: \(connReq)") + let userId = try currentUserId("apiConnectPlan") + let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) + if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } + logger.error("apiConnectPlan error: \(responseError(r))") + throw r +} + func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { @@ -610,10 +619,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } } - let alert = mkAlert( - title: "Contact already exists", - message: "You are already connected to \(contact.displayName)." - ) + let alert = contactAlreadyExistsAlert(contact) return (nil, alert) case .chatCmdError(_, .error(.invalidConnReq)): let alert = mkAlert( @@ -641,6 +647,13 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert return (nil, alert) } +func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connected to \(contact.displayName)." + ) +} + private func connectionErrorAlert(_ r: ChatResponse) -> Alert { if let networkErrorAlert = networkErrorAlert(r) { return networkErrorAlert diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 5ec14f5be1..3d10101dee 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -19,7 +19,7 @@ struct GroupMemberInfoView: View { @State private var connectionCode: String? = nil @State private var newRole: GroupMemberRole = .member @State private var alert: GroupMemberInfoViewAlert? - @State private var connectToMemberDialog: Bool = false + @State private var sheet: PlanAndConnectActionSheet? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var justOpened = true @State private var progressIndicator = false @@ -30,9 +30,8 @@ struct GroupMemberInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert - case connRequestSentAlert(type: ConnReqType) + case planAndConnectAlert(alert: PlanAndConnectAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey) - case other(alert: Alert) var id: String { switch self { @@ -41,9 +40,8 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" - case .connRequestSentAlert: return "connRequestSentAlert" + case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .error(title, _): return "error \(title)" - case let .other(alert): return "other \(alert)" } } } @@ -206,11 +204,11 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) - case let .connRequestSentAlert(type): return connReqSentAlert(type) + case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) - case let .other(alert): return alert } } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } if progressIndicator { ProgressView().scaleEffect(2) @@ -220,25 +218,16 @@ struct GroupMemberInfoView: View { func connectViaAddressButton(_ contactLink: String) -> some View { Button { - connectToMemberDialog = true + planAndConnect( + contactLink, + showAlert: { alert = .planAndConnectAlert(alert: $0) }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: nil + ) } label: { Label("Connect", systemImage: "link") } - .confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) { - Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) } - Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) } - } - } - - func connectViaAddress(incognito: Bool, contactLink: String) { - Task { - let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink) - if let connReqType = connReqType { - alert = .connRequestSentAlert(type: connReqType) - } else if let connectAlert = connectAlert { - alert = .other(alert: connectAlert) - } - } } func knownDirectChatButton(_ chat: Chat) -> some View { diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index a727ad6be0..e4fbd8bd47 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -58,65 +58,331 @@ struct NewChatButton: View { } } -enum ConnReqType: Equatable { - case contact - case invitation +enum PlanAndConnectAlert: Identifiable { + case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case invitationLinkConnecting(connectionLink: String) + case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) + + var id: String { + switch self { + case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" + case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" + case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" + case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" + case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" + } + } } -func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) { - Task { - if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { - DispatchQueue.main.async { - dismiss?() - AlertManager.shared.showAlert(connReqSentAlert(connReqType)) - } +func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { + switch alert { + case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own one-time link!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case .invitationLinkConnecting: + return Alert( + title: Text("Already connecting!"), + message: Text("You are already connecting via this one-time link!") + ) + case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own SimpleX address!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Join group?"), + message: Text("You will connect to all group members."), + primaryButton: .default( + Text(incognito ? "Join incognito" : "Join"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConnecting(_, groupInfo): + if let groupInfo = groupInfo { + return Alert( + title: Text("Group already exists!"), + message: Text("You are already joining the group \(groupInfo.displayName).") + ) } else { - DispatchQueue.main.async { - dismiss?() + return Alert( + title: Text("Already joining the group!"), + message: Text("You are already joining the group via this link.") + ) + } + } +} + +enum PlanAndConnectActionSheet: Identifiable { + case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) + case ownLinkAskCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) + + var id: String { + switch self { + case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" + case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, _, _): return "ownLinkAskCurrentOrIncognitoProfile \(connectionLink)" + case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" + } + } +} + +func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet { + switch sheet { + case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo): + if let incognito = incognito { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + } + } +} + +func planAndConnect( + _ connectionLink: String, + showAlert: @escaping (PlanAndConnectAlert) -> Void, + showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, + dismiss: Bool, + incognito: Bool? +) { + Task { + do { + let connectionPlan = try await apiConnectPlan(connReq: connectionLink) + switch connectionPlan { + case let .invitationLink(ilp): + switch ilp { + case .ok: + logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + } + case .ownLink: + logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + } + case let .connecting(contact_): + logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") + if let contact = contact_ { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } else { + showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) + } + case let .known(contact): + logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .contactAddress(cap): + switch cap { + case .ok: + logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + } + case .ownLink: + logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + } + case let .connecting(contact): + logger.debug("planAndConnect, .contactAddress, .connecting, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + case let .known(contact): + logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .groupLink(glp): + switch glp { + case .ok: + if let incognito = incognito { + showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + } + case let .ownLink(groupInfo): + logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) + case let .connecting(groupInfo_): + logger.debug("planAndConnect, .groupLink, .connecting, incognito=\(incognito?.description ?? "nil")") + showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + case let .known(groupInfo): + logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + } + } + } catch { + logger.debug("planAndConnect, plan error") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) } } } } -struct CReqClientData: Decodable { - var type: String - var groupLinkId: String? -} - -func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? { - if let hashIndex = connectionLink.firstIndex(of: "#"), - let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])), - let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false), - let data = components.queryItems?.first(where: { $0.name == "data" })?.value, - let d = data.data(using: .utf8), - let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) { - return crData - } else { - return nil +private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { + Task { + if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { + let crt: ConnReqType + if let plan = connectionPlan { + crt = planToConnReqType(plan) + } else { + crt = connReqType + } + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } else { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } + } else { + if dismiss { + DispatchQueue.main.async { + dismissAllSheets(animated: true) + } + } + } } } -func checkCRDataGroup(_ crData: CReqClientData) -> Bool { - return crData.type == "group" && crData.groupLinkId != nil +func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let c = m.getContactChat(contact.contactId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } + } + } } -func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert { - return Alert( - title: Text("Connect via group link?"), - message: Text("You will join a group this link refers to and connect to its group members."), - primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) { - connectViaLink(connectionLink, incognito: incognito) - }, - secondaryButton: .cancel() +func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let g = m.getGroupChat(groupInfo.groupId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } + } + } +} + +func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connecting to \(contact.displayName)." ) } +func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert { + mkAlert( + title: "Group already exists", + message: "You are already in group \(groupInfo.displayName)." + ) +} + +enum ConnReqType: Equatable { + case invitation + case contact + case groupLink + + var connReqSentText: LocalizedStringKey { + switch self { + case .invitation: return "You will be connected when your contact's device is online, please wait or check later!" + case .contact: return "You will be connected when your connection request is accepted, please wait or check later!" + case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!" + } + } +} + +private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { + switch connectionPlan { + case .invitationLink: return .invitation + case .contactAddress: return .contact + case .groupLink: return .groupLink + } +} + func connReqSentAlert(_ type: ConnReqType) -> Alert { return mkAlert( title: "Connection request sent!", - message: type == .contact - ? "You will be connected when your connection request is accepted, please wait or check later!" - : "You will be connected when your contact's device is online, please wait or check later!" + message: type.connReqSentText ) } diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index ec199327ce..af84f19fec 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -14,6 +14,8 @@ struct PasteToConnectView: View { @State private var connectionLink: String = "" @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @FocusState private var linkEditorFocused: Bool + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { List { @@ -57,6 +59,8 @@ struct PasteToConnectView: View { + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") } } + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } private func linkEditor() -> some View { @@ -83,13 +87,13 @@ struct PasteToConnectView: View { private func connect() { let link = connectionLink.trimmingCharacters(in: .whitespaces) - if let crData = parseLinkQueryData(link), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault)) - } else { - connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault) - } + planAndConnect( + link, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 01c8a4659e..c55ba1502e 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -13,6 +13,8 @@ import CodeScanner struct ScanToConnectView: View { @Environment(\.dismiss) var dismiss: DismissAction @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { ScrollView { @@ -49,18 +51,20 @@ struct ScanToConnectView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .background(Color(.systemGroupedBackground)) + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } func processQRCode(_ resp: Result) { switch resp { case let .success(r): - if let crData = parseLinkQueryData(r.string), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault)) - } else { - Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) } - } + planAndConnect( + r.string, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") dismiss() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 35d0b1de8a..2a90e75d85 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -176,6 +176,11 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */; }; + 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; + 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; + 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C812AD6B6B900B21C4C /* libffi.a */; }; + 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; @@ -458,6 +463,11 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; + 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; + 64AB9C812AD6B6B900B21C4C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; @@ -507,13 +517,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */, - 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */, - 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, - 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, + 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, + 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */, + 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */, + 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +584,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CA8D0122AD746C8001FD661 /* libffi.a */, - 5CA8D0152AD746C8001FD661 /* libgmp.a */, - 5CA8D0112AD746C8001FD661 /* libgmpxx.a */, - 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, - 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, + 64AB9C812AD6B6B900B21C4C /* libffi.a */, + 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */, + 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */, + 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, + 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d29e2481d5..9eb9b9084c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -86,6 +86,7 @@ public enum ChatCommand { case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) case apiAddContact(userId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) @@ -219,6 +220,7 @@ public enum ChatCommand { case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" + case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" @@ -335,6 +337,7 @@ public enum ChatCommand { case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiAddContact: return "apiAddContact" case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" + case .apiConnectPlan: return "apiConnectPlan" case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" @@ -460,6 +463,7 @@ public enum ChatResponse: Decodable, Error { case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) + case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef) case sentInvitation(user: UserRef) case contactAlreadyExists(user: UserRef, contact: Contact) @@ -601,6 +605,7 @@ public enum ChatResponse: Decodable, Error { case .connectionVerified: return "connectionVerified" case .invitation: return "invitation" case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" + case .connectionPlan: return "connectionPlan" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" case .contactAlreadyExists: return "contactAlreadyExists" @@ -739,6 +744,7 @@ public enum ChatResponse: Decodable, Error { case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) @@ -859,6 +865,33 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { } } +public enum ConnectionPlan: Decodable { + case invitationLink(invitationLinkPlan: InvitationLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan) + case groupLink(groupLinkPlan: GroupLinkPlan) +} + +public enum InvitationLinkPlan: Decodable { + case ok + case ownLink + case connecting(contact_: Contact?) + case known(contact: Contact) +} + +public enum ContactAddressPlan: Decodable { + case ok + case ownLink + case connecting(contact: Contact) + case known(contact: Contact) +} + +public enum GroupLinkPlan: Decodable { + case ok + case ownLink(groupInfo: GroupInfo) + case connecting(groupInfo_: GroupInfo?) + case known(groupInfo: GroupInfo) +} + struct NewUser: Encodable { var profile: Profile? var sameServers: Bool @@ -1477,6 +1510,7 @@ public enum ChatErrorType: Decodable { case chatNotStarted case chatNotStopped case chatStoreChanged + case connectionPlan(connectionPlan: ConnectionPlan) case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact)