diff --git a/apps/ios/LOCALIZATION.md b/apps/ios/LOCALIZATION.md index f58b8918b..b0c6c7088 100644 --- a/apps/ios/LOCALIZATION.md +++ b/apps/ios/LOCALIZATION.md @@ -11,7 +11,7 @@ There are three ways XCode generates localization keys from strings: 3. All strings wrapped in `NSLocalizedString`. Please note that such strings do not support swift interpolation, instead formatted strings should be used: ```swift -String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body") +String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), value) ``` ## Adding strings to the existing localizations diff --git a/apps/ios/Shared/DebugJSON.playground/Contents.swift b/apps/ios/Shared/DebugJSON.playground/Contents.swift new file mode 100644 index 000000000..e62ca1ab5 --- /dev/null +++ b/apps/ios/Shared/DebugJSON.playground/Contents.swift @@ -0,0 +1,20 @@ +import UIKit + +let s = """ + { + "contactConnection" : { + "contactConnection" : { + "viaContactUri" : false, + "pccConnId" : 456, + "pccAgentConnId" : "cTdjbmR4ZzVzSmhEZHdzMQ==", + "pccConnStatus" : "new", + "updatedAt" : "2022-04-24T11:59:23.703162Z", + "createdAt" : "2022-04-24T11:59:23.703162Z" + } + } + } +""" +//let s = "\"2022-04-24T11:59:23.703162Z\"" +let json = getJSONDecoder() +let d = s.data(using: .utf8)! +print (try! json.decode(ChatInfo.self, from: d)) diff --git a/apps/ios/Shared/DebugJSON.playground/contents.xcplayground b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground new file mode 100644 index 000000000..cf026f228 --- /dev/null +++ b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 2a747881f..e2960a6ea 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,9 +54,16 @@ final class ChatModel: ObservableObject { } } + func updateContactConnection(_ contactConnection: PendingContactConnection) { + updateChat(.contactConnection(contactConnection: contactConnection)) + } + func updateContact(_ contact: Contact) { - let cInfo = ChatInfo.direct(contact: contact) - if hasChat(contact.id) { + updateChat(.direct(contact: contact)) + } + + private func updateChat(_ cInfo: ChatInfo) { + if hasChat(cInfo.id) { updateChatInfo(cInfo) } else { addChat(Chat(chatInfo: cInfo, chatItems: [])) @@ -248,6 +255,7 @@ enum ChatType: String { case direct = "@" case group = "#" case contactRequest = "<@" + case contactConnection = ":" } protocol NamedChat { @@ -268,6 +276,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case direct(contact: Contact) case group(groupInfo: GroupInfo) case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) var localDisplayName: String { get { @@ -275,6 +284,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.localDisplayName case let .group(groupInfo): return groupInfo.localDisplayName case let .contactRequest(contactRequest): return contactRequest.localDisplayName + case let .contactConnection(contactConnection): return contactConnection.localDisplayName } } } @@ -285,6 +295,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.displayName case let .group(groupInfo): return groupInfo.displayName case let .contactRequest(contactRequest): return contactRequest.displayName + case let .contactConnection(contactConnection): return contactConnection.displayName } } } @@ -295,6 +306,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.fullName case let .group(groupInfo): return groupInfo.fullName case let .contactRequest(contactRequest): return contactRequest.fullName + case let .contactConnection(contactConnection): return contactConnection.fullName } } } @@ -305,6 +317,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.image case let .group(groupInfo): return groupInfo.image case let .contactRequest(contactRequest): return contactRequest.image + case let .contactConnection(contactConnection): return contactConnection.image } } } @@ -315,6 +328,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.id case let .group(groupInfo): return groupInfo.id case let .contactRequest(contactRequest): return contactRequest.id + case let .contactConnection(contactConnection): return contactConnection.id } } } @@ -325,6 +339,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case .direct: return .direct case .group: return .group case .contactRequest: return .contactRequest + case .contactConnection: return .contactConnection } } } @@ -335,6 +350,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.apiId case let .group(groupInfo): return groupInfo.apiId case let .contactRequest(contactRequest): return contactRequest.apiId + case let .contactConnection(contactConnection): return contactConnection.apiId } } } @@ -345,6 +361,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.ready case let .group(groupInfo): return groupInfo.ready case let .contactRequest(contactRequest): return contactRequest.ready + case let .contactConnection(contactConnection): return contactConnection.ready } } } @@ -354,6 +371,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { case let .direct(contact): return contact.createdAt case let .group(groupInfo): return groupInfo.createdAt case let .contactRequest(contactRequest): return contactRequest.createdAt + case let .contactConnection(contactConnection): return contactConnection.createdAt } } @@ -456,7 +474,7 @@ struct Contact: Identifiable, Decodable, NamedChat { var id: ChatId { get { "@\(contactId)" } } var apiId: Int64 { get { contactId } } - var ready: Bool { get { activeConn.connStatus == "ready" } } + var ready: Bool { get { activeConn.connStatus == .ready } } var displayName: String { get { profile.displayName } } var fullName: String { get { profile.fullName } } var image: String? { get { profile.image } } @@ -476,9 +494,15 @@ struct ContactSubStatus: Decodable { } struct Connection: Decodable { - var connStatus: String + var connId: Int64 + var connStatus: ConnStatus - static let sampleData = Connection(connStatus: "ready") + var id: ChatId { get { ":\(connId)" } } + + static let sampleData = Connection( + connId: 1, + connStatus: .ready + ) } struct UserContactRequest: Decodable, NamedChat { @@ -486,6 +510,7 @@ struct UserContactRequest: Decodable, NamedChat { var localDisplayName: ContactName var profile: Profile var createdAt: Date + var updatedAt: Date var id: ChatId { get { "<@\(contactRequestId)" } } var apiId: Int64 { get { contactRequestId } } @@ -498,10 +523,91 @@ struct UserContactRequest: Decodable, NamedChat { contactRequestId: 1, localDisplayName: "alice", profile: Profile.sampleData, - createdAt: .now + createdAt: .now, + updatedAt: .now ) } +struct PendingContactConnection: Decodable, NamedChat { + var pccConnId: Int64 + var pccAgentConnId: String + var pccConnStatus: ConnStatus + var viaContactUri: Bool + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { ":\(pccConnId)" } } + var apiId: Int64 { get { pccConnId } } + var ready: Bool { get { false } } + var localDisplayName: String { + get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } + } + var displayName: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("invited to connect", comment: "chat list item title") + : NSLocalizedString("connecting…", comment: "chat list item title") + } else { + // this should not be in the list + return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown") + } + } + } + var fullName: String { get { "" } } + var image: String? { get { nil } } + var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } + + var description: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("you shared one-time link", comment: "chat list item description") + : viaContactUri + ? NSLocalizedString("via contact address link", comment: "chat list item description") + : NSLocalizedString("via one-time link", comment: "chat list item description") + } else { + return "" + } + } + } + + static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { + PendingContactConnection( + pccConnId: 1, + pccAgentConnId: "abcd", + pccConnStatus: status, + viaContactUri: viaContactUri, + createdAt: .now, + updatedAt: .now + ) + } +} + +enum ConnStatus: String, Decodable { + case new = "new" + case joined = "joined" + case requested = "requested" + case accepted = "accepted" + case sndReady = "snd-ready" + case ready = "ready" + case deleted = "deleted" + + var initiated: Bool? { + get { + switch self { + case .new: return true + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return false + case .ready: return nil + case .deleted: return nil + } + } + } +} + struct GroupInfo: Identifiable, Decodable, NamedChat { var groupId: Int64 var localDisplayName: GroupName diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 524fd047b..af6803b79 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -52,7 +52,7 @@ enum ChatCommand { case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" case .startChat: return "/_start" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" - case .apiGetChats: return "/_get chats" + case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, file, quotedItemId, mc): switch (file, quotedItemId) { @@ -174,6 +174,8 @@ enum ChatResponse: Decodable, Error { case rcvFileAccepted case rcvFileComplete(chatItem: AChatItem) case ntfTokenStatus(status: NtfTknStatus) + case newContactConnection(connection: PendingContactConnection) + case contactConnectionDeleted(connection: PendingContactConnection) case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) @@ -220,6 +222,8 @@ enum ChatResponse: Decodable, Error { case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileComplete: return "rcvFileComplete" case .ntfTokenStatus: return "ntfTokenStatus" + case .newContactConnection: return "newContactConnection" + case .contactConnectionDeleted: return "contactConnectionDeleted" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" @@ -269,6 +273,8 @@ enum ChatResponse: Decodable, Error { case .rcvFileAccepted: return noDetails case let .rcvFileComplete(chatItem): return String(describing: chatItem) case let .ntfTokenStatus(status): return String(describing: status) + case let .newContactConnection(connection): return String(describing: connection) + case let .contactConnectionDeleted(connection): return String(describing: connection) case .cmdOk: return noDetails case let .chatCmdError(chatError): return String(describing: chatError) case let .chatError(chatError): return String(describing: chatError) @@ -538,7 +544,8 @@ func apiConnect(connReq: String) async throws -> ConnReqType? { func apiDeleteChat(type: ChatType, id: Int64) async throws { let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) - if case .contactDeleted = r { return } + if case .direct = type, case .contactDeleted = r { return } + if case .contactConnection = type, case .contactConnectionDeleted = r { return } throw r } @@ -608,7 +615,7 @@ func acceptContactRequest(_ contactRequest: UserContactRequest) async { let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) } } catch let error { - logger.error("acceptContactRequest error: \(error.localizedDescription)") + logger.error("acceptContactRequest error: \(responseError(error))") } } @@ -617,7 +624,7 @@ func rejectContactRequest(_ contactRequest: UserContactRequest) async { try await apiRejectContactRequest(contactReqId: contactRequest.apiId) DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) } } catch let error { - logger.error("rejectContactRequest: \(error.localizedDescription)") + logger.error("rejectContactRequest: \(responseError(error))") } } @@ -629,7 +636,7 @@ func markChatRead(_ chat: Chat) async { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange) DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) } } catch { - logger.error("markChatRead apiChatRead error: \(error.localizedDescription)") + logger.error("markChatRead apiChatRead error: \(responseError(error))") } } @@ -638,7 +645,7 @@ func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) } } catch { - logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)") + logger.error("markChatItemRead apiChatRead error: \(responseError(error))") } } @@ -701,33 +708,37 @@ class ChatReceiver { } func processReceivedMsg(_ res: ChatResponse) { - let chatModel = ChatModel.shared + let m = ChatModel.shared DispatchQueue.main.async { - chatModel.terminalItems.append(.resp(.now, res)) + m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { + case let .newContactConnection(contactConnection): + m.updateContactConnection(contactConnection) case let .contactConnected(contact): - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, .connected) + m.updateContact(contact) + m.removeChat(contact.activeConn.id) + m.updateNetworkStatus(contact, .connected) NtfManager.shared.notifyContactConnected(contact) case let .contactConnecting(contact): - chatModel.updateContact(contact) + m.updateContact(contact) + m.removeChat(contact.activeConn.id) case let .receivedContactRequest(contactRequest): - chatModel.addChat(Chat( + m.addChat(Chat( chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest), chatItems: [] )) NtfManager.shared.notifyContactRequest(contactRequest) case let .contactUpdated(toContact): let cInfo = ChatInfo.direct(contact: toContact) - if chatModel.hasChat(toContact.id) { - chatModel.updateChatInfo(cInfo) + if m.hasChat(toContact.id) { + m.updateChatInfo(cInfo) } case let .contactSubscribed(contact): processContactSubscribed(contact) case let .contactDisconnected(contact): - chatModel.updateContact(contact) - chatModel.updateNetworkStatus(contact, .disconnected) + m.updateContact(contact) + m.updateNetworkStatus(contact, .disconnected) case let .contactSubError(contact, chatError): processContactSubError(contact, chatError) case let .contactSubSummary(contactSubscriptions): @@ -741,7 +752,7 @@ func processReceivedMsg(_ res: ChatResponse) { case let .newChatItem(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - chatModel.addChatItem(cInfo, cItem) + m.addChatItem(cInfo, cItem) if let file = cItem.file, file.fileSize <= maxImageSize { Task { @@ -758,11 +769,11 @@ func processReceivedMsg(_ res: ChatResponse) { let cItem = aChatItem.chatItem var res = false if !cItem.isDeletedContent() { - res = chatModel.upsertChatItem(cInfo, cItem) + res = m.upsertChatItem(cInfo, cItem) } if res { NtfManager.shared.notifyMessageReceived(cInfo, cItem) - } else if let endTask = chatModel.messageDelivery[cItem.id] { + } else if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { case .sndSent: endTask() case .sndErrorAuth: endTask() @@ -773,22 +784,22 @@ func processReceivedMsg(_ res: ChatResponse) { case let .chatItemUpdated(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if chatModel.upsertChatItem(cInfo, cItem) { + if m.upsertChatItem(cInfo, cItem) { NtfManager.shared.notifyMessageReceived(cInfo, cItem) } case let .chatItemDeleted(_, toChatItem): let cInfo = toChatItem.chatInfo let cItem = toChatItem.chatItem if cItem.meta.itemDeleted { - chatModel.removeChatItem(cInfo, cItem) + m.removeChatItem(cInfo, cItem) } else { // currently only broadcast deletion of rcv message can be received, and only this case should happen - _ = chatModel.upsertChatItem(cInfo, cItem) + _ = m.upsertChatItem(cInfo, cItem) } case let .rcvFileComplete(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if chatModel.upsertChatItem(cInfo, cItem) { + if m.upsertChatItem(cInfo, cItem) { NtfManager.shared.notifyMessageReceived(cInfo, cItem) } default: diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index bf20e1bd3..78b6777ea 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -21,6 +21,8 @@ struct ChatListNavLink: View { groupNavLink(groupInfo) case let .contactRequest(cReq): contactRequestNavLink(cReq) + case let .contactConnection(cConn): + contactConnectionNavLink(cConn) } } @@ -125,6 +127,31 @@ struct ChatListNavLink: View { } } + private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { + ContactConnectionView(contactConnection: contactConnection) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection)) + } label: { + Label("Delete", systemImage: "trash") + } + } + .frame(height: 80) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: + contactConnection.initiated + ? "You invited your contact" + : "You accepted connection", + // below are the same messages that are shown in alert + message: + contactConnection.viaContactUri + ? "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!" + ) + } + } + private func deleteContactAlert(_ contact: Contact) -> Alert { Alert( title: Text("Delete contact?"), @@ -137,7 +164,7 @@ struct ChatListNavLink: View { chatModel.removeChat(contact.id) } } catch let error { - logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") + logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(responseError(error))") } } }, @@ -163,6 +190,29 @@ struct ChatListNavLink: View { ) } + private func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection) -> Alert { + Alert( + title: Text("Delete pending connection?"), + message: + contactConnection.initiated + ? Text("The contact you shared this link with will NOT be able to connect!") + : Text("The connection you accepted will be cancelled!"), + primaryButton: .destructive(Text("Delete")) { + Task { + do { + try await apiDeleteChat(type: .contactConnection, id: contactConnection.apiId) + DispatchQueue.main.async { + chatModel.removeChat(contactConnection.id) + } + } catch let error { + logger.error("ChatListNavLink.deleteContactConnectionAlert apiDeleteChat error: \(responseError(error))") + } + } + }, + secondaryButton: .cancel() + ) + } + private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { Alert( title: Text("Contact is not connected yet!"), diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 20b513315..094f916bd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -13,6 +13,7 @@ struct ChatListView: View { // not really used in this view @State private var showSettings = false @State private var searchText = "" + @AppStorage("pendingConnections") private var pendingConnections = true var user: User @@ -64,9 +65,16 @@ struct ChatListView: View { private func filteredChats() -> [Chat] { let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - return s == "" + return s == "" && pendingConnections ? chatModel.chats - : chatModel.chats.filter { $0.chatInfo.chatViewName.localizedLowercase.contains(s) } + : s == "" + ? chatModel.chats.filter { + pendingConnections || $0.chatInfo.chatType != .contactConnection + } + : chatModel.chats.filter { + (pendingConnections || $0.chatInfo.chatType != .contactConnection) && + $0.chatInfo.chatViewName.localizedLowercase.contains(s) + } } private func connectViaUrlAlert(_ url: URL) -> Alert { diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift new file mode 100644 index 000000000..90c9b70bd --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -0,0 +1,55 @@ +// +// ContactConnectionView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ContactConnectionView: View { + var contactConnection: PendingContactConnection + + var body: some View { + HStack(spacing: 8) { + Image(systemName: contactConnection.initiated ? "link.badge.plus" : "link") + .resizable() + .foregroundColor(Color(uiColor: .secondarySystemBackground)) + .scaledToFill() + .frame(width: 48, height: 48) + .frame(width: 63, height: 63) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text(contactConnection.chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.secondary) + .padding(.leading, 8) + .padding(.top, 4) + .frame(maxHeight: .infinity, alignment: .topLeading) + Spacer() + timestampText(contactConnection.updatedAt) + .font(.subheadline) + .padding(.trailing, 8) + .padding(.top, 4) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(.secondary) + } + Text(contactConnection.description) + .frame(minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding([.leading, .trailing], 8) + .padding(.bottom, 4) + .padding(.top, 1) + } + } + } +} + +struct ContactConnectionView_Previews: PreviewProvider { + static var previews: some View { + ContactConnectionView(contactConnection: PendingContactConnection.getSampleData()) + .previewLayout(.fixed(width: 360, height: 80)) + } +} diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 2eedbdeb4..31be17edf 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -20,7 +20,7 @@ struct ContactRequestView: View { .padding(.leading, 4) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top) { - Text(ChatInfo.contactRequest(contactRequest: contactRequest).chatViewName) + Text(contactRequest.chatViewName) .font(.title3) .fontWeight(.bold) .foregroundColor(.blue) @@ -28,7 +28,7 @@ struct ContactRequestView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - timestampText(contactRequest.createdAt) + timestampText(contactRequest.updatedAt) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index ff969c9ca..c2d329d10 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -18,7 +18,8 @@ struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool - @AppStorage("useNotifications") private var useNotifications: Bool = false + @AppStorage("useNotifications") private var useNotifications = false + @AppStorage("pendingConnections") private var pendingConnections = true @State var showNotificationsAlert: Bool = false @State var whichNotificationsAlert = NotificationAlert.enable @@ -59,6 +60,11 @@ struct SettingsView: View { } Section("Settings") { + HStack { + Image(systemName: "link") + .padding(.trailing, 8) + Toggle("Show pending connections", isOn: $pendingConnections) + } NavigationLink { SMPServers() .navigationTitle("Your SMP servers") diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 953e7abd5..9a6c0b7d2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; + 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; @@ -90,6 +91,8 @@ 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; + 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; + 5C13730C2815740A00F43030 /* DebugJSON.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = DebugJSON.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; @@ -286,6 +289,7 @@ 5CA059C5279559F40002BEB4 /* Assets.xcassets */, 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, 5C764E7F279C7276000C6508 /* dummy.m */, + 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); path = Shared; sourceTree = ""; @@ -342,6 +346,7 @@ 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */, 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, + 5C13730A28156D2700F43030 /* ContactConnectionView.swift */, ); path = ChatList; sourceTree = ""; @@ -483,6 +488,7 @@ 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a40773e9f..64321d7e6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -365,8 +365,11 @@ processChatCommand = \case unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted ct gs -> throwChatError $ CEContactGroups ct gs - CTContactConnection -> - CRContactConnectionDeleted <$> withStore (\st -> deletePendingContactConnection st userId chatId) + CTContactConnection -> withChatLock . procCmd $ do + conn <- withStore $ \st -> getPendingContactConnection st userId chatId + withAgent $ \a -> deleteConnection a $ aConnId' conn + withStore $ \st -> deletePendingContactConnection st userId chatId + pure $ CRContactConnectionDeleted conn CTGroup -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 257fde225..9c381a776 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -152,6 +152,7 @@ module Simplex.Chat.Store updateGroupChatItemsRead, getSMPServers, overwriteSMPServers, + getPendingContactConnection, deletePendingContactConnection, ) where @@ -2727,21 +2728,39 @@ getContactConnectionChatPreviews_ db User {userId} _ = stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} in AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats -deletePendingContactConnection :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m PendingContactConnection +getPendingContactConnection :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m PendingContactConnection +getPendingContactConnection st userId connId = + liftIOEither . withTransaction st $ \db -> do + firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ + DB.query + db + [sql| + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at + FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + |] + (userId, connId, ConnContact) + +deletePendingContactConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () deletePendingContactConnection st userId connId = - liftIOEither . withTransaction st $ \db -> runExceptT $ do - conn <- - ExceptT . firstRow toPendingContactConnection (SEPendingConnectionNotFound connId) $ - DB.query - db - [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, created_at, updated_at - FROM connections - WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL - |] - (userId, ConnContact) - liftIO $ DB.execute db "DELETE FROM connections WHERE connection_id = ?" (Only connId) - pure conn + liftIO . withTransaction st $ \db -> + DB.execute + db + [sql| + DELETE FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + |] + (userId, connId, ConnContact) toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, UTCTime, UTCTime) -> PendingContactConnection toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, createdAt, updatedAt) = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e8f064c2d..fcb11f7f5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -684,6 +684,9 @@ data PendingContactConnection = PendingContactConnection } deriving (Eq, Show, Generic) +aConnId' :: PendingContactConnection -> ConnId +aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId + instance ToJSON PendingContactConnection where toEncoding = J.genericToEncoding J.defaultOptions data ConnStatus