From f49ded5ae536b96f6f05c562b684d3fc12b647a9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 10 Nov 2023 10:16:06 +0400 Subject: [PATCH] ios: connect with contact via address (for preset simplex contact) (#3323) * ios: connect with contact via address (for preset simplex contact) * remove diff * remove floating button * refactor active * open chat * remove disabled * fix incognito --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 12 ++- apps/ios/Shared/Model/SimpleXAPI.swift | 30 ++++-- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/ChatList/ChatListNavLink.swift | 93 ++++++++++++++----- .../Shared/Views/ChatList/ChatListView.swift | 7 -- .../Views/ChatList/ChatPreviewView.swift | 7 +- .../Shared/Views/NewChat/NewChatButton.swift | 29 ++++++ apps/ios/SimpleXChat/APITypes.swift | 7 ++ apps/ios/SimpleXChat/ChatTypes.swift | 16 ++-- 11 files changed, 156 insertions(+), 51 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f562ea7f5..fe9032e7c 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -194,7 +194,7 @@ final class ChatModel: ObservableObject { func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) { var updatedConn = contact.activeConn - updatedConn.connectionStats = connectionStats + updatedConn?.connectionStats = connectionStats var updatedContact = contact updatedContact.activeConn = updatedConn updateContact(updatedContact) @@ -671,11 +671,17 @@ final class ChatModel: ObservableObject { } func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { - networkStatuses[contact.activeConn.agentConnId] = status + if let conn = contact.activeConn { + networkStatuses[conn.agentConnId] = status + } } func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { - networkStatuses[contact.activeConn.agentConnId] ?? .unknown + if let conn = contact.activeConn { + networkStatuses[conn.agentConnId] ?? .unknown + } else { + .unknown + } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 32e983a84..ec539b7fa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -675,6 +675,18 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert { } } +func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Contact?, Alert?) { + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiConnectContactViaAddress: no current user") + return (nil, nil) + } + let r = await chatSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId)) + if case let .sentInvitationToContact(_, contact, _) = r { return (contact, nil) } + logger.error("apiConnectContactViaAddress error: \(responseError(r))") + let alert = connectionErrorAlert(r) + return (nil, alert) +} + func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws { let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false) if case .direct = type, case .contactDeleted = r { return } @@ -1326,8 +1338,10 @@ func processReceivedMsg(_ res: ChatResponse) async { if active(user) && contact.directOrUsed { await MainActor.run { m.updateContact(contact) - m.dismissConnReqView(contact.activeConn.id) - m.removeChat(contact.activeConn.id) + if let conn = contact.activeConn { + m.dismissConnReqView(conn.id) + m.removeChat(conn.id) + } } } if contact.directOrUsed { @@ -1340,8 +1354,10 @@ func processReceivedMsg(_ res: ChatResponse) async { if active(user) && contact.directOrUsed { await MainActor.run { m.updateContact(contact) - m.dismissConnReqView(contact.activeConn.id) - m.removeChat(contact.activeConn.id) + if let conn = contact.activeConn { + m.dismissConnReqView(conn.id) + m.removeChat(conn.id) + } } } case let .receivedContactRequest(user, contactRequest): @@ -1480,9 +1496,9 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { m.updateGroup(groupInfo) - if let hostContact = hostContact { - m.dismissConnReqView(hostContact.activeConn.id) - m.removeChat(hostContact.activeConn.id) + if let conn = hostContact?.activeConn { + m.dismissConnReqView(conn.id) + m.removeChat(conn.id) } } case let .groupLinkConnecting(user, groupInfo, hostMember): diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index b90c9e747..b702c2cc2 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -338,7 +338,7 @@ struct ChatInfoView: View { verify: { code in if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) { let (verified, existingCode) = r - contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil + contact.activeConn?.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil connectionCode = existingCode DispatchQueue.main.async { chat.chatInfo = .direct(contact: contact) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index f276025dd..d8a560640 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -66,7 +66,7 @@ struct CIRcvDecryptionError: View { @ViewBuilder private func viewBody() -> some View { if case let .direct(contact) = chat.chatInfo, - let contactStats = contact.activeConn.connectionStats { + let contactStats = contact.activeConn?.connectionStats { if contactStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncContactConnection(contact) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5e5a7f8b5..1f7cf1e37 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -114,7 +114,7 @@ struct ChatView: View { connectionStats = stats customUserProfile = profile connectionCode = code - if contact.activeConn.connectionCode != ct.activeConn.connectionCode { + if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode { chat.chatInfo = .direct(contact: ct) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 971c0e088..18464b3bb 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -33,6 +33,7 @@ struct ChatListNavLink: View { @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false @State private var showDeleteContactActionSheet = false + @State private var showConnectContactViaAddressDialog = false @State private var inProgress = false @State private var progressByTimeout = false @@ -63,32 +64,52 @@ struct ChatListNavLink: View { } @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { - NavLinkPlain( - tag: chat.chatInfo.id, - selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } - ) - .swipeActions(edge: .leading, allowsFullSwipe: true) { - markReadButton() - toggleFavoriteButton() - toggleNtfsButton(chat) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - if !chat.chatItems.isEmpty { - clearChatButton() - } - Button { - if contact.ready || !contact.active { - showDeleteContactActionSheet = true - } else { - AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) + Group { + if contact.activeConn == nil && contact.profile.contactLink != nil { + ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) + .frame(height: rowHeights[dynamicTypeSize]) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + showDeleteContactActionSheet = true + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .onTapGesture { showConnectContactViaAddressDialog = true } + .confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) { + Button("Use current profile") { connectContactViaAddress_(contact, false) } + Button("Use new incognito profile") { connectContactViaAddress_(contact, true) } + } + } else { + NavLinkPlain( + tag: chat.chatInfo.id, + selection: $chatModel.chatId, + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } + ) + .swipeActions(edge: .leading, allowsFullSwipe: true) { + markReadButton() + toggleFavoriteButton() + toggleNtfsButton(chat) } - } label: { - Label("Delete", systemImage: "trash") + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if !chat.chatItems.isEmpty { + clearChatButton() + } + Button { + if contact.ready || !contact.active { + showDeleteContactActionSheet = true + } else { + AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) + } + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .frame(height: rowHeights[dynamicTypeSize]) } - .tint(.red) } - .frame(height: rowHeights[dynamicTypeSize]) .actionSheet(isPresented: $showDeleteContactActionSheet) { if contact.ready && contact.active { return ActionSheet( @@ -411,6 +432,17 @@ struct ChatListNavLink: View { .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } } + + private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) { + Task { + let ok = await connectContactViaAddress(contact.contactId, incognito) + if ok { + await MainActor.run { + chatModel.chatId = contact.id + } + } + } + } } func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { @@ -439,6 +471,21 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, ) } +func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool { + let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId) + if let alert = alert { + AlertManager.shared.showAlert(alert) + return false + } else if let contact = contact { + await MainActor.run { + ChatModel.shared.updateContact(contact) + AlertManager.shared.showAlert(connReqSentAlert(.contact)) + } + return true + } + return false +} + func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { Task { logger.debug("joinGroup") diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index baab2bcf9..a006f333f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -177,13 +177,6 @@ struct ChatListView: View { showAddChat = true } - connectButton("or chat with the developers") { - DispatchQueue.main.async { - UIApplication.shared.open(simplexTeamURL) - } - } - .padding(.top, 10) - Spacer() Text("You have no chats") .foregroundColor(.secondary) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 71f8baf74..30068114f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -190,7 +190,10 @@ struct ChatPreviewView: View { } else { switch (chat.chatInfo) { case let .direct(contact): - if !contact.ready { + if contact.activeConn == nil && contact.profile.contactLink != nil { + chatPreviewInfoText("Tap to Connect") + .foregroundColor(.accentColor) + } else if !contact.ready && contact.activeConn != nil { if contact.nextSendGrpInv { chatPreviewInfoText("send direct message") } else if contact.active { @@ -238,7 +241,7 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { switch chat.chatInfo { case let .direct(contact): - if contact.active { + if contact.active && contact.activeConn != nil { switch (chatModel.contactNetworkStatus(contact)) { case .connected: incognitoIcon(chat.chatInfo.incognito) case .error: diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 8d095e907..637c01032 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -155,12 +155,14 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { 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)" } } @@ -186,6 +188,15 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool .cancel() ] ) + case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): + return ActionSheet( + title: Text("Connect with \(contact.chatViewName)"), + buttons: [ + .default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false) }, + .default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo): if let incognito = incognito { return ActionSheet( @@ -277,6 +288,13 @@ func planAndConnect( case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") 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) + } else { + showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact)) + } } case let .groupLink(glp): switch glp { @@ -315,6 +333,17 @@ func planAndConnect( } } +private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool) { + Task { + if dismiss { + DispatchQueue.main.async { + dismissAllSheets(animated: true) + } + } + _ = await connectContactViaAddress(contact.contactId, incognito) + } +} + private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { Task { if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3b0b4de04..c19e65c9d 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -90,6 +90,7 @@ public enum ChatCommand { case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) + case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) @@ -226,6 +227,7 @@ public enum ChatCommand { 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 .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" case let .apiDeleteChat(type, id, notify): if let notify = notify { return "/_delete \(ref(type, id)) notify=\(onOff(notify))" } else { @@ -297,6 +299,7 @@ public enum ChatCommand { case .apiSendMessage: return "apiSendMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" case .apiChatItemReaction: return "apiChatItemReaction" case .apiGetNtfToken: return "apiGetNtfToken" @@ -478,6 +481,7 @@ public enum ChatResponse: Decodable, Error { case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef) case sentInvitation(user: UserRef) + case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) @@ -622,6 +626,7 @@ public enum ChatResponse: Decodable, Error { case .connectionPlan: return "connectionPlan" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" + case .sentInvitationToContact: return "sentInvitationToContact" case .contactAlreadyExists: return "contactAlreadyExists" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactDeleted: return "contactDeleted" @@ -763,6 +768,7 @@ public enum ChatResponse: Decodable, Error { case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails + case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) @@ -902,6 +908,7 @@ public enum ContactAddressPlan: Decodable { case connectingConfirmReconnect case connectingProhibit(contact: Contact) case known(contact: Contact) + case contactViaAddress(contact: Contact) } public enum GroupLinkPlan: Decodable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 25511e1ba..f8a6d78a5 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1370,7 +1370,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var contactId: Int64 var localDisplayName: ContactName public var profile: LocalProfile - public var activeConn: Connection + public var activeConn: Connection? public var viaGroup: Int64? public var contactUsed: Bool public var contactStatus: ContactStatus @@ -1384,10 +1384,10 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } - public var ready: Bool { get { activeConn.connStatus == .ready } } + public var ready: Bool { get { activeConn?.connStatus == .ready } } public var active: Bool { get { contactStatus == .active } } public var sendMsgEnabled: Bool { get { - (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) + (ready && active && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false)) || nextSendGrpInv } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } @@ -1396,14 +1396,18 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var image: String? { get { profile.image } } public var contactLink: String? { get { profile.contactLink } } public var localAlias: String { profile.localAlias } - public var verified: Bool { activeConn.connectionCode != nil } + public var verified: Bool { activeConn?.connectionCode != nil } public var directOrUsed: Bool { - (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed + if let activeConn = activeConn { + (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed + } else { + true + } } public var contactConnIncognito: Bool { - activeConn.customUserProfileId != nil + activeConn?.customUserProfileId != nil } public func allowsFeature(_ feature: ChatFeature) -> Bool {