// // NewChatView.swift // SimpleX (iOS) // // Created by spaced4ndy on 28.11.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // import SwiftUI import SimpleXChat import CodeScanner import AVFoundation import SimpleXChat struct SomeAlert: Identifiable { var alert: Alert var id: String } struct SomeActionSheet: Identifiable { var actionSheet: ActionSheet var id: String } struct SomeSheet: Identifiable { @ViewBuilder var content: Content var id: String } private enum NewChatViewAlert: Identifiable { case planAndConnectAlert(alert: PlanAndConnectAlert) case newChatSomeAlert(alert: SomeAlert) var id: String { switch self { case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .newChatSomeAlert(alert): return "newChatSomeAlert \(alert.id)" } } } enum NewChatOption: Identifiable { case invite case connect var id: Self { self } } func cleanupPendingConnection(contactConnection: PendingContactConnection?) -> SomeAlert? { var alert: SomeAlert? = nil if !(ChatModel.shared.showingInvitation?.connChatUsed ?? true), let conn = contactConnection { alert = SomeAlert( alert: Alert( title: Text("Keep unused invitation?"), message: Text("You can view invitation link again in connection details."), primaryButton: .default(Text("Keep")) {}, secondaryButton: .destructive(Text("Delete")) { Task { await deleteChat(Chat( chatInfo: .contactConnection(contactConnection: conn), chatItems: [] )) } } ), id: "keepUnusedInvitation" ) } ChatModel.shared.showingInvitation = nil return alert } struct NewChatView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @State var selection: NewChatOption @State var showQRCodeScanner = false @State private var invitationUsed: Bool = false @State private var connReqInvitation: String = "" @State private var creatingConnReq = false @State var choosingProfile = false @State private var pastedLink: String = "" @State private var alert: NewChatViewAlert? @Binding var parentAlert: SomeAlert? @Binding var contactConnection: PendingContactConnection? var body: some View { VStack(alignment: .leading) { Picker("New chat", selection: $selection) { Label("Add contact", systemImage: "link") .tag(NewChatOption.invite) Label("Connect via link", systemImage: "qrcode") .tag(NewChatOption.connect) } .pickerStyle(.segmented) .padding() .onChange(of: $selection.wrappedValue) { opt in if opt == NewChatOption.connect { showQRCodeScanner = true } } VStack { // it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions // https://developer.apple.com/forums/thread/714977?answerId=731615022#731615022 if case .invite = selection { prepareAndInviteView() .transition(.move(edge: .leading)) .onAppear { createInvitation() } } if case .connect = selection { ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert) .transition(.move(edge: .trailing)) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .modifier(ThemedBackground(grouped: true)) .background( // Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton) Rectangle() .fill(theme.base == DefaultTheme.LIGHT ? theme.colors.background.asGroupedBackground(theme.base.mode) : theme.colors.background) ) .animation(.easeInOut(duration: 0.3333), value: selection) .gesture(DragGesture(minimumDistance: 20.0, coordinateSpace: .local) .onChanged { value in switch(value.translation.width, value.translation.height) { case (...0, -30...30): // left swipe if selection == .invite { selection = .connect } case (0..., -30...30): // right swipe if selection == .connect { selection = .invite } default: () } } ) } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { InfoSheetButton { AddContactLearnMore(showTitle: true) } } } .modifier(ThemedBackground(grouped: true)) .onChange(of: invitationUsed) { used in if used && !(m.showingInvitation?.connChatUsed ?? true) { m.markShowingInvitationUsed() } } .onDisappear { if !choosingProfile { parentAlert = cleanupPendingConnection(contactConnection: contactConnection) contactConnection = nil } } .alert(item: $alert) { a in switch(a) { case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true, cleanup: { pastedLink = "" }) case let .newChatSomeAlert(a): return a.alert } } } private func prepareAndInviteView() -> some View { ZStack { // ZStack is needed for views to not make transitions between each other if connReqInvitation != "" { InviteView( invitationUsed: $invitationUsed, contactConnection: $contactConnection, connReqInvitation: $connReqInvitation, choosingProfile: $choosingProfile ) } else if creatingConnReq { creatingLinkProgressView() } else { retryButton() } } } private func createInvitation() { if connReqInvitation == "" && contactConnection == nil && !creatingConnReq { creatingConnReq = true Task { _ = try? await Task.sleep(nanoseconds: 250_000000) let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get()) if let (connReq, pcc) = r { await MainActor.run { m.updateContactConnection(pcc) m.showingInvitation = ShowingInvitation(connId: pcc.id, connChatUsed: false) connReqInvitation = connReq contactConnection = pcc } } else { await MainActor.run { creatingConnReq = false if let apiAlert = apiAlert { alert = .newChatSomeAlert(alert: SomeAlert(alert: apiAlert, id: "createInvitation error")) } } } } } } // Rectangle here and in retryButton are needed for gesture to work private func creatingLinkProgressView() -> some View { ProgressView("Creating link…") .progressViewStyle(.circular) } private func retryButton() -> some View { Button(action: createInvitation) { VStack(spacing: 6) { Image(systemName: "arrow.counterclockwise") Text("Retry") } } } } private func incognitoProfileImage() -> some View { Image(systemName: "theatermasks.fill") .resizable() .scaledToFit() .frame(width: 30) .foregroundColor(.indigo) } private struct InviteView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var invitationUsed: Bool @Binding var contactConnection: PendingContactConnection? @Binding var connReqInvitation: String @Binding var choosingProfile: Bool @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { List { Section(header: Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary)) { shareLinkView() } .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) qrCodeView() if let selectedProfile = chatModel.currentUser { Section { NavigationLink { ActiveProfilePicker( contactConnection: $contactConnection, connReqInvitation: $connReqInvitation, incognitoEnabled: $incognitoDefault, choosingProfile: $choosingProfile, selectedProfile: selectedProfile ) } label: { HStack { if incognitoDefault { incognitoProfileImage() Text("Incognito") } else { ProfileImage(imageStr: chatModel.currentUser?.image, size: 30) Text(chatModel.currentUser?.chatViewName ?? "") } } } } header: { Text("Share profile").foregroundColor(theme.colors.secondary) } footer: { if incognitoDefault { Text("A new random profile will be shared.") } } } } .onChange(of: incognitoDefault) { incognito in setInvitationUsed() } .onChange(of: chatModel.currentUser) { u in setInvitationUsed() } } private func shareLinkView() -> some View { HStack { let link = simplexChatLink(connReqInvitation) linkTextView(link) Button { showShareSheet(items: [link]) setInvitationUsed() } label: { Image(systemName: "square.and.arrow.up") .padding(.top, -7) } } .frame(maxWidth: .infinity) } private func qrCodeView() -> some View { Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) { SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed) .id("simplex-qrcode-view-for-\(connReqInvitation)") .padding() .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) ) .padding(.horizontal) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } } private func setInvitationUsed() { if !invitationUsed { invitationUsed = true } } } private enum ProfileSwitchStatus { case switchingUser case switchingIncognito case idle } private struct ActiveProfilePicker: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var contactConnection: PendingContactConnection? @Binding var connReqInvitation: String @Binding var incognitoEnabled: Bool @Binding var choosingProfile: Bool @State private var alert: SomeAlert? @State private var profileSwitchStatus: ProfileSwitchStatus = .idle @State private var switchingProfileByTimeout = false @State private var lastSwitchingProfileByTimeoutCall: Double? @State private var profiles: [User] = [] @State private var searchTextOrPassword = "" @State private var showIncognitoSheet = false @State private var incognitoFirst: Bool = false @State var selectedProfile: User var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)} var body: some View { viewBody() .navigationTitle("Select chat profile") .searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always)) .autocorrectionDisabled(true) .navigationBarTitleDisplayMode(.large) .onAppear { profiles = chatModel.users .map { $0.user } .sorted { u, _ in u.activeUser } } .onChange(of: incognitoEnabled) { incognito in if profileSwitchStatus != .switchingIncognito { return } Task { do { if let contactConn = contactConnection, let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { await MainActor.run { contactConnection = conn chatModel.updateContactConnection(conn) profileSwitchStatus = .idle dismiss() } } } catch { profileSwitchStatus = .idle incognitoEnabled = !incognito logger.error("apiSetConnectionIncognito error: \(responseError(error))") let err = getErrorAlert(error, "Error changing to incognito!") alert = SomeAlert( alert: Alert( title: Text(err.title), message: Text(err.message ?? "Error: \(responseError(error))") ), id: "setConnectionIncognitoError" ) } } } .onChange(of: profileSwitchStatus) { sp in if sp != .idle { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { switchingProfileByTimeout = profileSwitchStatus != .idle } } else { switchingProfileByTimeout = false } } .onChange(of: selectedProfile) { profile in if (profileSwitchStatus != .switchingUser) { return } Task { do { if let contactConn = contactConnection, let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) { await MainActor.run { contactConnection = conn connReqInvitation = conn.connReqInv ?? "" incognitoEnabled = false chatModel.updateContactConnection(conn) } do { try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil ) await MainActor.run { profileSwitchStatus = .idle dismiss() } } catch { await MainActor.run { profileSwitchStatus = .idle alert = SomeAlert( alert: Alert( title: Text("Error switching profile"), message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.") ), id: "switchingProfileError" ) } } } } catch { await MainActor.run { profileSwitchStatus = .idle if let currentUser = chatModel.currentUser { selectedProfile = currentUser } let err = getErrorAlert(error, "Error changing connection profile") alert = SomeAlert( alert: Alert( title: Text(err.title), message: Text(err.message ?? "Error: \(responseError(error))") ), id: "changeConnectionUserError" ) } } } } .alert(item: $alert) { a in a.alert } .onAppear { incognitoFirst = incognitoEnabled choosingProfile = true } .onDisappear { choosingProfile = false } .sheet(isPresented: $showIncognitoSheet) { IncognitoHelp() } } @ViewBuilder private func viewBody() -> some View { profilePicker() .allowsHitTesting(!switchingProfileByTimeout) .modifier(ThemedBackground(grouped: true)) .overlay { if switchingProfileByTimeout { ProgressView() .scaleEffect(2) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } private func filteredProfiles() -> [User] { let s = trimmedSearchTextOrPassword let lower = s.localizedLowercase return profiles.filter { u in if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) { return true } return correctPassword(u, s) } } @ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View { Button { if selectedProfile == user && incognitoEnabled { incognitoEnabled = false profileSwitchStatus = .switchingIncognito } else if selectedProfile != user { selectedProfile = user profileSwitchStatus = .switchingUser } } label: { HStack { ProfileImage(imageStr: user.image, size: 30) .padding(.trailing, 2) Text(user.chatViewName) .foregroundColor(theme.colors.onBackground) .lineLimit(1) Spacer() if selectedProfile == user, !incognitoEnabled { Image(systemName: "checkmark") .resizable().scaledToFit().frame(width: 16) .foregroundColor(theme.colors.primary) } } } } @ViewBuilder private func profilePicker() -> some View { let incognitoOption = Button { if !incognitoEnabled { incognitoEnabled = true profileSwitchStatus = .switchingIncognito } } label : { HStack { incognitoProfileImage() Text("Incognito") .foregroundColor(theme.colors.onBackground) Image(systemName: "info.circle") .foregroundColor(theme.colors.primary) .font(.system(size: 14)) .onTapGesture { showIncognitoSheet = true } Spacer() if incognitoEnabled { Image(systemName: "checkmark") .resizable().scaledToFit().frame(width: 16) .foregroundColor(theme.colors.primary) } } } List { let filteredProfiles = filteredProfiles() let activeProfile = filteredProfiles.first { u in u.activeUser } if let selectedProfile = activeProfile { let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId } if incognitoFirst { incognitoOption profilerPickerUserOption(selectedProfile) } else { profilerPickerUserOption(selectedProfile) incognitoOption } ForEach(otherProfiles) { p in profilerPickerUserOption(p) } } else { incognitoOption ForEach(filteredProfiles) { p in profilerPickerUserOption(p) } } } .opacity(switchingProfileByTimeout ? 0.4 : 1) } } private struct ConnectView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @Binding var showQRCodeScanner: Bool @Binding var pastedLink: String @Binding var alert: NewChatViewAlert? @State private var sheet: PlanAndConnectActionSheet? @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings var body: some View { List { Section(header: Text("Paste the link you received").foregroundColor(theme.colors.secondary)) { pasteLinkView() } Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) { ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode) } } .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" }) } } @ViewBuilder private func pasteLinkView() -> some View { if pastedLink == "" { Button { if let str = UIPasteboard.general.string { if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) { pastedLink = link.text // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner // https://github.com/twostraws/CodeScanner/issues/121 // No known tricks worked (changing view ID, wrapping it in another view, etc.) // showQRCodeScanner = false connect(pastedLink) } else { alert = .newChatSomeAlert(alert: SomeAlert( alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."), id: "pasteLinkView: code is not a SimpleX link" )) } } } label: { Text("Tap to paste link") } .disabled(!pasteboardHasStrings) .frame(maxWidth: .infinity, alignment: .center) } else { linkTextView(pastedLink) } } private func processQRCode(_ resp: Result) { switch resp { case let .success(r): let link = r.string if strIsSimplexLink(r.string) { connect(link) } else { alert = .newChatSomeAlert(alert: SomeAlert( alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."), id: "processQRCode: code is not a SimpleX link" )) } case let .failure(e): logger.error("processQRCode QR code error: \(e.localizedDescription)") alert = .newChatSomeAlert(alert: SomeAlert( alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"), id: "processQRCode: failure" )) } } private func connect(_ link: String) { planAndConnect( link, showAlert: { alert = .planAndConnectAlert(alert: $0) }, showActionSheet: { sheet = $0 }, dismiss: true, incognito: nil ) } } struct ScannerInView: View { @Binding var showQRCodeScanner: Bool let processQRCode: (_ resp: Result) -> Void @State private var cameraAuthorizationStatus: AVAuthorizationStatus? var scanMode: ScanMode = .continuous var body: some View { Group { if showQRCodeScanner, case .authorized = cameraAuthorizationStatus { CodeScannerView(codeTypes: [.qr], scanMode: scanMode, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .padding(.horizontal) } else { Button { switch cameraAuthorizationStatus { case .notDetermined: askCameraAuthorization { showQRCodeScanner = true } case .restricted: () case .denied: UIApplication.shared.open(appSettingsURL) case .authorized: showQRCodeScanner = true default: askCameraAuthorization { showQRCodeScanner = true } } } label: { ZStack { Rectangle() .aspectRatio(contentMode: .fill) .frame(maxWidth: .infinity, maxHeight: .infinity) .foregroundColor(Color.clear) switch cameraAuthorizationStatus { case .authorized, nil: EmptyView() case .restricted: Text("Camera not available") case .denied: Label("Enable camera access", systemImage: "camera") default: Label("Tap to scan", systemImage: "qrcode") } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding() .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(uiColor: .secondarySystemGroupedBackground)) ) .padding(.horizontal) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .disabled(cameraAuthorizationStatus == .restricted) } } .task { let status = AVCaptureDevice.authorizationStatus(for: .video) cameraAuthorizationStatus = status if showQRCodeScanner { switch status { case .notDetermined: await askCameraAuthorizationAsync() case .restricted: showQRCodeScanner = false case .denied: showQRCodeScanner = false case .authorized: () @unknown default: await askCameraAuthorizationAsync() } } } } func askCameraAuthorizationAsync() async { await AVCaptureDevice.requestAccess(for: .video) cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) } func askCameraAuthorization(_ cb: (() -> Void)? = nil) { AVCaptureDevice.requestAccess(for: .video) { allowed in cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) if allowed { cb?() } } } } private func linkTextView(_ link: String) -> some View { Text(link) .lineLimit(1) .font(.caption) .truncationMode(.middle) } struct InfoSheetButton: View { @ViewBuilder let content: Content @State private var showInfoSheet = false var body: some View { Button { showInfoSheet = true } label: { Image(systemName: "info.circle") .resizable() .scaledToFit() .frame(width: 24, height: 24) } .sheet(isPresented: $showInfoSheet) { content } } } func strIsSimplexLink(_ str: String) -> Bool { if let parsedMd = parseSimpleXMarkdown(str), parsedMd.count == 1, case .simplexLink = parsedMd[0].format { return true } else { return false } } func strHasSingleSimplexLink(_ str: String) -> FormattedText? { if let parsedMd = parseSimpleXMarkdown(str) { let parsedLinks = parsedMd.filter({ $0.format?.isSimplexLink ?? false }) if parsedLinks.count == 1 { return parsedLinks[0] } else { return nil } } else { return nil } } struct IncognitoToggle: View { @EnvironmentObject var theme: AppTheme @Binding var incognitoEnabled: Bool @State private var showIncognitoSheet = false var body: some View { ZStack(alignment: .leading) { Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks") .frame(maxWidth: 24, maxHeight: 24, alignment: .center) .foregroundColor(incognitoEnabled ? Color.indigo : theme.colors.secondary) .font(.system(size: 14)) Toggle(isOn: $incognitoEnabled) { HStack(spacing: 6) { Text("Incognito") Image(systemName: "info.circle") .foregroundColor(theme.colors.primary) .font(.system(size: 14)) } .onTapGesture { showIncognitoSheet = true } } .padding(.leading, 36) } .sheet(isPresented: $showIncognitoSheet) { IncognitoHelp() } } } func sharedProfileInfo(_ incognito: Bool) -> Text { let name = ChatModel.shared.currentUser?.displayName ?? "" return Text( incognito ? "A new random profile will be shared." : "Your profile **\(name)** will be shared." ) } enum PlanAndConnectAlert: Identifiable { case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case invitationLinkConnecting(connectionLink: String) case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConnectingConfirmReconnect(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 .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)" case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)" case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" } } } func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (() -> Void)? = nil) -> 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, cleanup: cleanup) } ), secondaryButton: .cancel() { cleanup?() } ) case .invitationLinkConnecting: return Alert( title: Text("Already connecting!"), message: Text("You are already connecting via this one-time link!"), dismissButton: .default(Text("OK")) { cleanup?() } ) 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, cleanup: cleanup) } ), secondaryButton: .cancel() { cleanup?() } ) case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): return Alert( title: Text("Repeat connection request?"), message: Text("You have already requested connection via this address!"), primaryButton: .destructive( Text(incognito ? "Connect incognito" : "Connect"), action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } ), secondaryButton: .cancel() { cleanup?() } ) 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, cleanup: cleanup) } ), secondaryButton: .cancel() { cleanup?() } ) case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): return Alert( title: Text("Repeat join request?"), message: Text("You are already joining the group via this link!"), primaryButton: .destructive( Text(incognito ? "Join incognito" : "Join"), action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } ), secondaryButton: .cancel() { cleanup?() } ) 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)."), dismissButton: .default(Text("OK")) { cleanup?() } ) } else { return Alert( title: Text("Already joining the group!"), message: Text("You are already joining the group via this link."), dismissButton: .default(Text("OK")) { cleanup?() } ) } } } enum PlanAndConnectActionSheet: Identifiable { case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact) case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) var id: String { switch self { case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)" case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" } } } func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool, cleanup: (() -> Void)? = nil) -> 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, cleanup: cleanup) }, .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) }, .cancel() { cleanup?() } ] ) case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title): return ActionSheet( title: Text(title), buttons: [ .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) }, .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) }, .cancel() { cleanup?() } ] ) case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return ActionSheet( title: Text("Connect with \(contact.chatViewName)"), buttons: [ .default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup) }, .default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup) }, .cancel() { cleanup?() } ] ) 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, cleanup: cleanup) }, .cancel() { cleanup?() } ] ) } 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, cleanup: cleanup) }, .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) }, .cancel() { cleanup?() } ] ) } } } func planAndConnect( _ connectionLink: String, showAlert: @escaping (PlanAndConnectAlert) -> Void, showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, dismiss: Bool, incognito: Bool?, cleanup: (() -> Void)? = nil, filterKnownContact: ((Contact) -> Void)? = nil, filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { 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, cleanup: cleanup) } 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(.askCurrentOrIncognitoProfileDestructive(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_ { if let f = filterKnownContact { f(contact) } else { 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")") if let f = filterKnownContact { f(contact) } else { 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, cleanup: cleanup) } 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(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) } case .connectingConfirmReconnect: logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") if let incognito = incognito { showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) } else { showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) } case let .connectingProhibit(contact): logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") if let f = filterKnownContact { f(contact) } else { openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } } case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") if let f = filterKnownContact { f(contact) } else { openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } } case let .contactViaAddress(contact): logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")") if let incognito = incognito { connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup) } else { showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: 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")") if let f = filterKnownGroup { f(groupInfo) } showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) case .connectingConfirmReconnect: logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") if let incognito = incognito { showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) } else { showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) } case let .connectingProhibit(groupInfo_): logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) case let .known(groupInfo): logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") if let f = filterKnownGroup { f(groupInfo) } else { 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, cleanup: cleanup) } else { showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) } } } } private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? = nil) { Task { if dismiss { DispatchQueue.main.async { dismissAllSheets(animated: true) } } let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) if ok { AlertManager.shared.showAlert(connReqSentAlert(.contact)) } cleanup?() } } private func connectViaLink( _ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? ) { Task { if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) { await MainActor.run { ChatModel.shared.updateContactConnection(pcc) } 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) } } } cleanup?() } } 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) { ItemsModel.shared.loadOpenChat(c.id) showAlreadyExistsAlert?() } } else { ItemsModel.shared.loadOpenChat(c.id) showAlreadyExistsAlert?() } } } } } 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) { ItemsModel.shared.loadOpenChat(g.id) showAlreadyExistsAlert?() } } else { ItemsModel.shared.loadOpenChat(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.connReqSentText ) } struct NewChatView_Previews: PreviewProvider { static var previews: some View { @State var parentAlert: SomeAlert? @State var contactConnection: PendingContactConnection? = nil NewChatView( selection: .invite, parentAlert: $parentAlert, contactConnection: $contactConnection ) } }