diff --git a/.gitignore b/.gitignore index 8b4cc543b6..e645225e93 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,6 @@ *.so *.dylib -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - # Dependency directories (remove the comment below to include it) # vendor/ @@ -42,9 +36,9 @@ cabal.project.local~ .ghc.environment.* stack.yaml.lock -# Idris -*.ibc - -# chat database +# Chat database *.db *.db.bak + +# Temporary test files +tests/tmp diff --git a/README.md b/README.md index 370965983b..602017ea9a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # SimpleX Chat -SimpleX - the most private and secure open-source chat and applications platform - now with double-ratchet E2E encryption. +SimpleX - private and secure open-source chat and application platform - public beta for iOS now available! [![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild) [![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases) @@ -10,11 +10,11 @@ SimpleX - the most private and secure open-source chat and applications platform [![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/simplexchat) [![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) -SimpleX Chat is a terminal (command line) UI using [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker. +SimpleX Chat apps (both terminal UI and [iOS public beta](https://testflight.apple.com/join/DWuT2LQu)) use [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker. See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. -**v1.0.0 is released: [read announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md)** +***SimpleX Chat [public beta for iOS 15 is available via TestFlight](https://testflight.apple.com/join/DWuT2LQu)** - it will help us a lot if you test it! [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220214-simplex-chat-ios-public-beta.md).* ### :zap: Quick installation diff --git a/apps/android/.idea/misc.xml b/apps/android/.idea/misc.xml index 0daaee7f8b..345106f12f 100644 --- a/apps/android/.idea/misc.xml +++ b/apps/android/.idea/misc.xml @@ -5,7 +5,7 @@ - + diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json index e30e4bc7ce..241e667fb5 100644 --- a/apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json +++ b/apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "github32px.png", + "filename" : "github_1x.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "github64px.png", + "filename" : "github_2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "github64px-1.png", + "filename" : "github_3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png deleted file mode 100644 index 8b25551a97..0000000000 Binary files a/apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png and /dev/null differ diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png deleted file mode 100644 index 182a1a3f73..0000000000 Binary files a/apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png and /dev/null differ diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png deleted file mode 100644 index 182a1a3f73..0000000000 Binary files a/apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png and /dev/null differ diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github_1x.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github_1x.png new file mode 100644 index 0000000000..41be900a34 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github.imageset/github_1x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github_2x.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github_2x.png new file mode 100644 index 0000000000..4cef050214 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github.imageset/github_2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/github.imageset/github_3x.png b/apps/ios/Shared/Assets.xcassets/github.imageset/github_3x.png new file mode 100644 index 0000000000..0ccb70bbb0 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github.imageset/github_3x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/github_light.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/github_light.imageset/Contents.json new file mode 100644 index 0000000000..88e84c950b --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/github_light.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "github_light_1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "github_light_2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "github_light_3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_1x.png b/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_1x.png new file mode 100644 index 0000000000..732eb7a048 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_1x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_2x.png b/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_2x.png new file mode 100644 index 0000000000..4fc75012bc Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_2x.png differ diff --git a/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_3x.png b/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_3x.png new file mode 100644 index 0000000000..a9881b6bca Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_3x.png differ diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 0392e7274b..24cd35b187 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -9,6 +9,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var chatModel: ChatModel + @ObservedObject var alertManager = AlertManager.shared @State private var showNotificationAlert = false var body: some View { @@ -23,27 +24,50 @@ struct ContentView: View { } ChatReceiver.shared.start() NtfManager.shared.requestAuthorization(onDeny: { - showNotificationAlert = true + alertManager.showAlert(notificationAlert()) }) } - .alert(isPresented : $showNotificationAlert){ - Alert( - title: Text("Notification are disabled!"), - message: Text("Please open settings to enable"), - primaryButton: .default(Text("Open Settings")) { - DispatchQueue.main.async { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) - } - }, - secondaryButton: .cancel() - ) - } + .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } else { WelcomeView() } } + + func notificationAlert() -> Alert { + Alert( + title: Text("Notification are disabled!"), + message: Text("Please open settings to enable"), + primaryButton: .default(Text("Open Settings")) { + DispatchQueue.main.async { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) + } + }, + secondaryButton: .cancel() + ) + } } +final class AlertManager: ObservableObject { + static let shared = AlertManager() + @Published var presentAlert = false + @Published var alertView: Alert? + + func showAlert(_ alert: Alert) { + logger.debug("AlertManager.showAlert") + DispatchQueue.main.async { + self.alertView = alert + self.presentAlert = true + } + } + + func showAlertMsg(title: String, message: String? = nil) { + if let message = message { + showAlert(Alert(title: Text(title), message: Text(message))) + } else { + showAlert(Alert(title: Text(title))) + } + } +} //struct ContentView_Previews: PreviewProvider { // static var previews: some View { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 91265fb85f..3aa05f6fed 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -17,11 +17,11 @@ final class ChatModel: ObservableObject { // current chat @Published var chatId: String? @Published var chatItems: [ChatItem] = [] + @Published var chatToTop: String? // items in the terminal view @Published var terminalItems: [TerminalItem] = [] @Published var userAddress: String? @Published var appOpenUrl: URL? - @Published var connectViaUrl = false static let shared = ChatModel() func hasChat(_ id: String) -> Bool { @@ -43,8 +43,8 @@ final class ChatModel: ObservableObject { } func updateChatInfo(_ cInfo: ChatInfo) { - if let ix = getChatIndex(cInfo.id) { - chats[ix].chatInfo = cInfo + if let i = getChatIndex(cInfo.id) { + chats[i].chatInfo = cInfo } } @@ -64,8 +64,8 @@ final class ChatModel: ObservableObject { } func replaceChat(_ id: String, _ chat: Chat) { - if let ix = chats.firstIndex(where: { $0.id == id }) { - chats[ix] = chat + if let i = getChatIndex(id) { + chats[i] = chat } else { // invalid state, correcting chats.insert(chat, at: 0) @@ -73,23 +73,101 @@ final class ChatModel: ObservableObject { } func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { - if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) { - chats[ix].chatItems = [cItem] - if ix > 0 { + // update previews + if let i = getChatIndex(cInfo.id) { + chats[i].chatItems = [cItem] + if case .rcvNew = cItem.meta.itemStatus { + chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 + } + if i > 0 { if chatId == nil { - withAnimation { popChat(ix) } + withAnimation { popChat_(i) } + } else if chatId == cInfo.id { + chatToTop = cInfo.id } else { - DispatchQueue.main.async { self.popChat(ix) } + popChat_(i) + } + } + } else { + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + } + // add to current chat + if chatId == cInfo.id { + withAnimation { chatItems.append(cItem) } + if case .rcvNew = cItem.meta.itemStatus { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if self.chatId == cInfo.id { + SimpleX.markChatItemRead(cInfo, cItem) + } } } } + } + + func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { + // update previews + var res: Bool + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last, pItem.id == cItem.id { + chat.chatItems = [cItem] + } + res = false + } else { + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + res = true + } + // update current chat if chatId == cInfo.id { - withAnimation { chatItems.append(cItem) } + if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { + withAnimation(.default) { + self.chatItems[i] = cItem + } + return false + } else { + withAnimation { chatItems.append(cItem) } + return true + } + } else { + return res } } - private func popChat(_ ix: Int) { - let chat = chats.remove(at: ix) + func markChatItemsRead(_ cInfo: ChatInfo) { + // update preview + if let chat = getChat(cInfo.id) { + chat.chatStats = ChatStats() + } + // update current chat + if chatId == cInfo.id { + var i = 0 + while i < chatItems.count { + if case .rcvNew = chatItems[i].meta.itemStatus { + chatItems[i].meta.itemStatus = .rcvRead + } + i = i + 1 + } + } + } + + func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) { + // update preview + if let i = getChatIndex(cInfo.id) { + chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1 + } + // update current chat + if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) { + chatItems[j].meta.itemStatus = .rcvRead + } + } + + func popChat(_ id: String) { + if let i = getChatIndex(id) { + popChat_(i) + } + } + + private func popChat_(_ i: Int) { + let chat = chats.remove(at: i) chats.insert(chat, at: 0) } @@ -107,14 +185,6 @@ struct User: Decodable, NamedChat { var profile: Profile var activeUser: Bool -// internal init(userId: Int64, userContactId: Int64, localDisplayName: ContactName, profile: Profile, activeUser: Bool) { -// self.userId = userId -// self.userContactId = userContactId -// self.localDisplayName = localDisplayName -// self.profile = profile -// self.activeUser = activeUser -// } - var displayName: String { get { profile.displayName } } var fullName: String { get { profile.fullName } } @@ -226,6 +296,16 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } + var ready: Bool { + get { + switch self { + case let .direct(contact): return contact.ready + case let .group(groupInfo): return groupInfo.ready + case let .contactRequest(contactRequest): return contactRequest.ready + } + } + } + var createdAt: Date { switch self { case let .direct(contact): return contact.createdAt @@ -250,6 +330,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { final class Chat: ObservableObject, Identifiable { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] + @Published var chatStats: ChatStats @Published var serverInfo = ServerInfo(networkStatus: .unknown) struct ServerInfo: Decodable { @@ -297,11 +378,13 @@ final class Chat: ObservableObject, Identifiable { init(_ cData: ChatData) { self.chatInfo = cData.chatInfo self.chatItems = cData.chatItems + self.chatStats = cData.chatStats } - init(chatInfo: ChatInfo, chatItems: [ChatItem] = []) { + init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) { self.chatInfo = chatInfo self.chatItems = chatItems + self.chatStats = chatStats } var id: ChatId { get { chatInfo.id } } @@ -310,10 +393,16 @@ final class Chat: ObservableObject, Identifiable { struct ChatData: Decodable, Identifiable { var chatInfo: ChatInfo var chatItems: [ChatItem] + var chatStats: ChatStats var id: ChatId { get { chatInfo.id } } } +struct ChatStats: Decodable { + var unreadCount: Int = 0 + var minUnreadItemId: Int64 = 0 +} + struct Contact: Identifiable, Decodable, NamedChat { var contactId: Int64 var localDisplayName: ContactName @@ -351,6 +440,7 @@ struct UserContactRequest: Decodable, NamedChat { var id: ChatId { get { "<@\(contactRequestId)" } } var apiId: Int64 { get { contactRequestId } } + var ready: Bool { get { true } } var displayName: String { get { profile.displayName } } var fullName: String { get { profile.fullName } } @@ -370,6 +460,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat { var id: ChatId { get { "#\(groupId)" } } var apiId: Int64 { get { groupId } } + var ready: Bool { get { true } } var displayName: String { get { groupProfile.displayName } } var fullName: String { get { groupProfile.fullName } } @@ -424,10 +515,17 @@ struct ChatItem: Identifiable, Decodable { var id: Int64 { get { meta.itemId } } - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String) -> ChatItem { + var timestampText: String { get { meta.timestampText } } + + func isRcvNew() -> Bool { + if case .rcvNew = meta.itemStatus { return true } + return false + } + + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> ChatItem { ChatItem( chatDir: dir, - meta: CIMeta.getSample(id, ts, text), + meta: CIMeta.getSample(id, ts, text, status), content: .sndMsgContent(msgContent: .text(text)) ) } @@ -455,23 +553,32 @@ struct CIMeta: Decodable { var itemId: Int64 var itemTs: Date var itemText: String + var itemStatus: CIStatus var createdAt: Date - static func getSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta { + var timestampText: String { get { SimpleX.timestampText(itemTs) } } + + static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta { CIMeta( itemId: id, itemTs: ts, itemText: text, + itemStatus: status, createdAt: ts ) } } + +func timestampText(_ date: Date) -> String { + date.formatted(date: .omitted, time: .shortened) +} + enum CIStatus: Decodable { case sndNew case sndSent case sndErrorAuth - case sndError(agentErrorType: AgentErrorType) + case sndError(agentError: AgentErrorType) case rcvNew case rcvRead } @@ -479,18 +586,25 @@ enum CIStatus: Decodable { enum CIContent: Decodable { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) - // files etc. + case sndFileInvitation(fileId: Int64, filePath: String) + case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer) var text: String { get { switch self { case let .sndMsgContent(mc): return mc.text case let .rcvMsgContent(mc): return mc.text + case .sndFileInvitation: return "sending files is not supported yet" + case .rcvFileInvitation: return "receiving files is not supported yet" } } } } +struct RcvFileTransfer: Decodable { + +} + enum MsgContent { case text(String) case unknown(type: String, text: String) diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 904357eec6..073c91c0d2 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -56,10 +56,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { let model = ChatModel.shared if UIApplication.shared.applicationState == .active { switch content.categoryIdentifier { - case ntfCategoryContactRequest: - return [.sound, .banner, .list] - case ntfCategoryContactConnected: - return model.chatId == nil ? [.sound, .list] : [.sound, .banner, .list] case ntfCategoryMessageReceived: if model.chatId == nil { // in the chat list diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 60daa0871f..051f6df905 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -31,6 +31,7 @@ enum ChatCommand { case showMyAddress case apiAcceptContact(contactReqId: Int64) case apiRejectContact(contactReqId: Int64) + case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case string(String) var cmdString: String { @@ -40,21 +41,50 @@ enum ChatCommand { case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" case .startChat: return "/_start" case .apiGetChats: return "/_get chats" - case let .apiGetChat(type, id): return "/_get chat \(type.rawValue)\(id) count=500" - case let .apiSendMessage(type, id, mc): return "/_send \(type.rawValue)\(id) \(mc.cmdString)" + case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" + case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)" case .addContact: return "/connect" case let .connect(connReq): return "/connect \(connReq)" - case let .apiDeleteChat(type, id): return "/_delete \(type.rawValue)\(id)" + case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .string(str): return str } } } + + var cmdType: String { + get { + switch self { + case .showActiveUser: return "showActiveUser" + case .createActiveUser: return "createActiveUser" + case .startChat: return "startChat" + case .apiGetChats: return "apiGetChats" + case .apiGetChat: return "apiGetChat" + case .apiSendMessage: return "apiSendMessage" + case .addContact: return "addContact" + case .connect: return "connect" + case .apiDeleteChat: return "apiDeleteChat" + case .updateProfile: return "updateProfile" + case .createMyAddress: return "createMyAddress" + case .deleteMyAddress: return "deleteMyAddress" + case .showMyAddress: return "showMyAddress" + case .apiAcceptContact: return "apiAcceptContact" + case .apiRejectContact: return "apiRejectContact" + case .apiChatRead: return "apiChatRead" + case .string: return "console command" + } + } + } + + func ref(_ type: ChatType, _ id: Int64) -> String { + "\(type.rawValue)\(id)" + } } struct APIResponse: Decodable { @@ -88,6 +118,8 @@ enum ChatResponse: Decodable, Error { case groupEmpty(groupInfo: GroupInfo) case userContactLinkSubscribed case newChatItem(chatItem: AChatItem) + case chatItemUpdated(chatItem: AChatItem) + case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) @@ -120,6 +152,8 @@ enum ChatResponse: Decodable, Error { case .groupEmpty: return "groupEmpty" case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .newChatItem: return "newChatItem" + case .chatItemUpdated: return "chatItemUpdated" + case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" } @@ -155,6 +189,8 @@ enum ChatResponse: Decodable, Error { case let .groupEmpty(groupInfo): return String(describing: groupInfo) case .userContactLinkSubscribed: return noDetails case let .newChatItem(chatItem): return String(describing: chatItem) + case let .chatItemUpdated(chatItem): return String(describing: chatItem) + case .cmdOk: return noDetails case let .chatCmdError(chatError): return String(describing: chatError) case let .chatError(chatError): return String(describing: chatError) } @@ -198,7 +234,9 @@ enum TerminalItem: Identifiable { func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! + logger.debug("chatSendCmd \(cmd.cmdType)") let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!) + logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") DispatchQueue.main.async { ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) ChatModel.shared.terminalItems.append(.resp(.now, resp)) @@ -315,6 +353,12 @@ func apiRejectContactRequest(contactReqId: Int64) throws { throw r } +func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) throws { + let r = try chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange)) + if case .cmdOk = r { return } + throw r +} + func acceptContactRequest(_ contactRequest: UserContactRequest) { do { let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId) @@ -334,6 +378,27 @@ func rejectContactRequest(_ contactRequest: UserContactRequest) { } } +func markChatRead(_ chat: Chat) { + do { + let minItemId = chat.chatStats.minUnreadItemId + let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId) + let cInfo = chat.chatInfo + try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange) + ChatModel.shared.markChatItemsRead(cInfo) + } catch { + logger.error("markChatRead apiChatRead error: \(error.localizedDescription)") + } +} + +func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) { + do { + try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) + ChatModel.shared.markChatItemRead(cInfo, cItem) + } catch { + logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)") + } +} + func initializeChat() { do { ChatModel.shared.currentUser = try apiGetActiveUser() @@ -419,6 +484,12 @@ func processReceivedMsg(_ res: ChatResponse) { let cItem = aChatItem.chatItem chatModel.addChatItem(cInfo, cItem) NtfManager.shared.notifyMessageReceived(cInfo, cItem) + case let .chatItemUpdated(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + if chatModel.upsertChatItem(cInfo, cItem) { + NtfManager.shared.notifyMessageReceived(cInfo, cItem) + } default: logger.debug("unsupported event: \(res.responseType)") } @@ -504,6 +575,7 @@ enum ChatErrorType: Decodable { case chatNotStarted case invalidConnReq case invalidChatMessage(message: String) + case contactNotReady(contact: Contact) case contactGroups(contact: Contact, groupNames: [GroupName]) case groupUserRole case groupContactRole(contactName: ContactName) diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 2334c70e76..392857d7be 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -28,7 +28,6 @@ struct SimpleXApp: App { .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") chatModel.appOpenUrl = url - chatModel.connectViaUrl = true } .onAppear() { initializeChat() diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift new file mode 100644 index 0000000000..774e8aa1f7 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -0,0 +1,43 @@ +// +// ChatInfoToolbar.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 11/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9) +private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 ) +struct ChatInfoToolbar: View { + @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat + + var body: some View { + let cInfo = chat.chatInfo + return HStack { + ChatInfoImage( + chat: chat, + color: colorScheme == .dark + ? chatImageColorDark + : chatImageColorLight + ) + .frame(width: 32, height: 32) + .padding(.trailing, 4) + VStack { + Text(cInfo.displayName).font(.headline) + if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName { + Text(cInfo.fullName).font(.subheadline) + } + } + } + .foregroundColor(.primary) + } +} + +struct ChatInfoToolbar_Previews: PreviewProvider { + static var previews: some View { + ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 9799db243f..078f05c530 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -10,11 +10,9 @@ import SwiftUI struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel + @ObservedObject var alertManager = AlertManager.shared @ObservedObject var chat: Chat @Binding var showChatInfo: Bool - @State private var showDeleteContactAlert = false - @State private var alertContact: Contact? - @State private var showNetworkStatusInfo = false var body: some View { VStack{ @@ -30,36 +28,27 @@ struct ChatInfoView: View { if case let .direct(contact) = chat.chatInfo { VStack { HStack { - Button { - showNetworkStatusInfo.toggle() - } label: { - serverImage() - Text(chat.serverInfo.networkStatus.statusString) - .foregroundColor(.primary) - } - } - if showNetworkStatusInfo { - Text(chat.serverInfo.networkStatus.statusExplanation) - .font(.subheadline) - .multilineTextAlignment(.center) - .padding(.horizontal, 64) - .padding(.vertical, 8) + serverImage() + Text(chat.serverInfo.networkStatus.statusString) + .foregroundColor(.primary) } + Text(chat.serverInfo.networkStatus.statusExplanation) + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal, 64) + .padding(.vertical, 8) Spacer() Button(role: .destructive) { - alertContact = contact - showDeleteContactAlert = true + alertManager.showAlert(deleteContactAlert(contact)) } label: { Label("Delete contact", systemImage: "trash") } .padding() - .alert(isPresented: $showDeleteContactAlert) { - deleteContactAlert(alertContact!) - } } } } + .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } @@ -81,10 +70,8 @@ struct ChatInfoView: View { } catch let error { logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") } - alertContact = nil - }, secondaryButton: .cancel() { - alertContact = nil - } + }, + secondaryButton: .cancel() ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift new file mode 100644 index 0000000000..5aa6b98eab --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -0,0 +1,47 @@ +// +// CIMetaView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 11/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct CIMetaView: View { + var chatItem: ChatItem + + var body: some View { + HStack(alignment: .center, spacing: 4) { + switch chatItem.meta.itemStatus { + case .sndSent: + statusImage("checkmark", .secondary) + case .sndErrorAuth: + statusImage("multiply", .red) + case .sndError: + statusImage("exclamationmark.triangle.fill", .yellow) + case .rcvNew: + statusImage("circlebadge.fill", Color.accentColor) + default: EmptyView() + } + + Text(chatItem.timestampText) + .font(.caption) + .foregroundColor(.secondary) + } + } + + private func statusImage(_ systemName: String, _ color: Color) -> some View { + Image(systemName: systemName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(color) + .frame(maxHeight: 8) + } +} + +struct CIMetaView_Previews: PreviewProvider { + static var previews: some View { + CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 2ee4a93e24..7a4ff3b5f2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -13,16 +13,15 @@ struct EmojiItemView: View { var body: some View { let sent = chatItem.chatDir.sent + let s = chatItem.content.text.trimmingCharacters(in: .whitespaces) - VStack { - Text(chatItem.content.text.trimmingCharacters(in: .whitespaces)) - .font(emojiFont) + VStack(spacing: 1) { + Text(s) + .font(s.count < 4 ? largeEmojiFont : mediumEmojiFont) .padding(.top, 8) .padding(.horizontal, 6) .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) - Text(getDateFormatter().string(from: chatItem.meta.itemTs)) - .font(.caption) - .foregroundColor(.secondary) + CIMetaView(chatItem: chatItem) .padding(.bottom, 8) .padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) @@ -35,7 +34,7 @@ struct EmojiItemView: View { struct EmojiItemView_Previews: PreviewProvider { static var previews: some View { Group{ - EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂")) + EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent)) EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) } .previewLayout(.fixed(width: 360, height: 70)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift index 0a396b16fc..be3cfdaada 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -12,7 +12,7 @@ private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=? private let phoneRegex = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$") -private let sentColorLigth = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) +private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) private let linkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) @@ -24,30 +24,22 @@ struct TextItemView: View { var body: some View { let sent = chatItem.chatDir.sent -// let minWidth = min(200, width) let maxWidth = width * 0.78 - let meta = getDateFormatter().string(from: chatItem.meta.itemTs) return ZStack(alignment: .bottomTrailing) { - (messageText(chatItem) + reserveSpaceForMeta(meta)) - .padding(.top, 6) - .padding(.bottom, 7) + (messageText(chatItem) + reserveSpaceForMeta(chatItem.timestampText)) + .padding(.vertical, 6) .padding(.horizontal, 12) .frame(minWidth: 0, alignment: .leading) -// .foregroundColor(sent ? .white : .primary) .textSelection(.enabled) - Text(meta) - .font(.caption) - .foregroundColor(.secondary) -// .foregroundColor(sent ? Color(uiColor: .secondarySystemBackground) : .secondary) - .padding(.bottom, 4) - .padding(.horizontal, 12) + CIMetaView(chatItem: chatItem) + .padding(.trailing, 12) + .padding(.bottom, 6) } -// .background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground)) .background( sent - ? (colorScheme == .light ? sentColorLigth : sentColorDark) + ? (colorScheme == .light ? sentColorLight : sentColorDark) : Color(uiColor: .tertiarySystemGroupedBackground) ) .cornerRadius(18) @@ -57,6 +49,13 @@ struct TextItemView: View { maxHeight: .infinity, alignment: sent ? .trailing : .leading ) + .onTapGesture { + switch chatItem.meta.itemStatus { + case .sndErrorAuth: msgDeliveryError("Most likely this contact has deleted the connection with you.") + case let .sndError(agentError): msgDeliveryError("Unexpected error: \(String(describing: agentError))") + default: return + } + } } private func messageText(_ chatItem: ChatItem) -> Text { @@ -82,10 +81,9 @@ struct TextItemView: View { } private func reserveSpaceForMeta(_ meta: String) -> Text { - Text(AttributedString(" \(meta)", attributes: AttributeContainer([ - .font: UIFont.preferredFont(forTextStyle: .caption1) as Any, - .foregroundColor: UIColor.clear as Any, - ]))) + Text(" \(meta)") + .font(.caption) + .foregroundColor(.clear) } private func wordToText(_ s: String.SubSequence) -> Text { @@ -126,6 +124,13 @@ struct TextItemView: View { private func mdText(_ s: String.SubSequence) -> Text { Text(s[s.index(s.startIndex, offsetBy: 1).. Bool { func isShortEmoji(_ str: String) -> Bool { let s = str.trimmingCharacters(in: .whitespaces) - return s.count > 0 && s.count <= 4 && s.allSatisfy(isEmoji) + return s.count > 0 && s.count <= 5 && s.allSatisfy(isEmoji) } -let emojiFont = Font.custom("Emoji", size: 48, relativeTo: .largeTitle) +let largeEmojiFont = Font.custom("Emoji", size: 48, relativeTo: .largeTitle) +let mediumEmojiFont = Font.custom("Emoji", size: 36, relativeTo: .largeTitle) diff --git a/apps/ios/Shared/Views/Chat/SendMessageView.swift b/apps/ios/Shared/Views/Chat/SendMessageView.swift index 60e9144568..af639999fc 100644 --- a/apps/ios/Shared/Views/Chat/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/SendMessageView.swift @@ -73,7 +73,11 @@ struct SendMessageView: View { func updateHeight(_ g: GeometryProxy) -> Color { DispatchQueue.main.async { teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) - teFont = isShortEmoji(message) ? emojiFont : .body + teFont = isShortEmoji(message) + ? message.count < 4 + ? largeEmojiFont + : mediumEmojiFont + : .body } return Color.clear } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f707660477..75da1fea98 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -11,14 +11,7 @@ import SwiftUI struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @State var chat: Chat - - @State private var showDeleteContactAlert = false - @State private var showDeleteGroupAlert = false - @State private var showContactRequestAlert = false @State private var showContactRequestDialog = false - @State private var alertContact: Contact? - @State private var alertGroupInfo: GroupInfo? - @State private var alertContactRequest: UserContactRequest? var body: some View { switch chat.chatInfo { @@ -46,64 +39,72 @@ struct ChatListNavLink: View { } private func contactNavLink(_ contact: Contact) -> some View { - NavigationLink( + NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, destination: { chatView() }, - label: { ChatPreviewView(chat: chat) } + label: { ChatPreviewView(chat: chat) }, + disabled: !contact.ready ) - .disabled(!contact.ready) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + .swipeActions(edge: .leading) { + if chat.chatStats.unreadCount > 0 { + markReadButton() + } + } + .swipeActions(edge: .trailing) { Button(role: .destructive) { - alertContact = contact - showDeleteContactAlert = true + AlertManager.shared.showAlert(deleteContactAlert(contact)) } label: { Label("Delete", systemImage: "trash") } } - .alert(isPresented: $showDeleteContactAlert) { - deleteContactAlert(alertContact!) - } .frame(height: 80) } private func groupNavLink(_ groupInfo: GroupInfo) -> some View { - NavigationLink( + NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, destination: { chatView() }, - label: { ChatPreviewView(chat: chat) } + label: { ChatPreviewView(chat: chat) }, + disabled: !groupInfo.ready ) + .swipeActions(edge: .leading) { + if chat.chatStats.unreadCount > 0 { + markReadButton() + } + } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { - alertGroupInfo = groupInfo - showDeleteGroupAlert = true + AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) } label: { Label("Delete", systemImage: "trash") } } - .alert(isPresented: $showDeleteGroupAlert) { - deleteGroupAlert(alertGroupInfo!) - } .frame(height: 80) } + private func markReadButton() -> some View { + Button { + markChatRead(chat) + } label: { + Label("Read", systemImage: "checkmark") + } + .tint(Color.accentColor) + } + private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { acceptContactRequest(contactRequest) } label: { Label("Accept", systemImage: "checkmark") } - .tint(.blue) + .tint(Color.accentColor) Button(role: .destructive) { - alertContactRequest = contactRequest - showContactRequestAlert = true + AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) } label: { Label("Reject", systemImage: "multiply") } } - .alert(isPresented: $showContactRequestAlert) { - contactRequestAlert(alertContactRequest!) - } .frame(height: 80) .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) { @@ -123,10 +124,8 @@ struct ChatListNavLink: View { } catch let error { logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)") } - alertContact = nil - }, secondaryButton: .cancel() { - alertContact = nil - } + }, + secondaryButton: .cancel() ) } @@ -137,16 +136,14 @@ struct ChatListNavLink: View { ) } - private func contactRequestAlert(_ contactRequest: UserContactRequest) -> Alert { + private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert { Alert( title: Text("Reject contact request"), message: Text("The sender will NOT be notified"), primaryButton: .destructive(Text("Reject")) { rejectContactRequest(contactRequest) - alertContactRequest = nil - }, secondaryButton: .cancel { - alertContactRequest = nil - } + }, + secondaryButton: .cancel() ) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index af43f7badc..4f5d044716 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -10,15 +10,14 @@ import SwiftUI struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel - @State private var connectAlert = false - @State private var connectError: Error? // not really used in this view @State private var showSettings = false + @State private var searchText = "" var user: User var body: some View { - NavigationView { + let v = NavigationView { List { if chatModel.chats.isEmpty { VStack(alignment: .leading) { @@ -30,13 +29,27 @@ struct ChatListView: View { .padding(.leading) } } - ForEach(chatModel.chats) { chat in + ForEach(filteredChats()) { chat in ChatListNavLink(chat: chat) + .padding(.trailing, -16) + } + } + .onChange(of: chatModel.chatId) { _ in + if chatModel.chatId == nil, let chatId = chatModel.chatToTop { + chatModel.chatToTop = nil + chatModel.popChat(chatId) + } + } + .onChange(of: chatModel.appOpenUrl) { _ in + if let url = chatModel.appOpenUrl { + chatModel.appOpenUrl = nil + AlertManager.shared.showAlert(connectViaUrlAlert(url)) } } .offset(x: -8) .listStyle(.plain) .navigationTitle(chatModel.chats.isEmpty ? "Welcome \(user.displayName)!" : "Your chats") + .navigationBarTitleDisplayMode(chatModel.chats.count > 8 ? .inline : .large) .toolbar { ToolbarItem(placement: .navigationBarLeading) { SettingsButton() @@ -45,49 +58,49 @@ struct ChatListView: View { NewChatButton() } } - .alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() } } .navigationViewStyle(.stack) - .alert(isPresented: $connectAlert) { connectionErrorAlert() } - } - private func connectViaUrlAlert() -> Alert { - logger.debug("ChatListView.connectViaUrlAlert") - if let url = chatModel.appOpenUrl { - var path = url.path - logger.debug("ChatListView.connectViaUrlAlert path: \(path)") - if (path == "/contact" || path == "/invitation") { - path.removeFirst() - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - return Alert( - title: Text("Connect via \(path) link?"), - message: Text("Your profile will be sent to the contact that you received this link from: \(link)"), - primaryButton: .default(Text("Connect")) { - do { - try apiConnect(connReq: link) - } catch { - connectAlert = true - connectError = error - logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(error.localizedDescription)") - } - chatModel.appOpenUrl = nil - }, secondaryButton: .cancel() { - chatModel.appOpenUrl = nil - } - ) - } else { - return Alert(title: Text("Error: URL is invalid")) - } + if chatModel.chats.count > 8 { + v.searchable(text: $searchText) } else { - return Alert(title: Text("Error: URL not available")) + v } } - private func connectionErrorAlert() -> Alert { - Alert( - title: Text("Connection error"), - message: Text(connectError?.localizedDescription ?? "") - ) + private func filteredChats() -> [Chat] { + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + return s == "" + ? chatModel.chats + : chatModel.chats.filter { $0.chatInfo.chatViewName.localizedLowercase.contains(s) } + } + + private func connectViaUrlAlert(_ url: URL) -> Alert { + var path = url.path + logger.debug("ChatListView.connectViaUrlAlert path: \(path)") + if (path == "/contact" || path == "/invitation") { + path.removeFirst() + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + return Alert( + title: Text("Connect via \(path) link?"), + message: Text("Your profile will be sent to the contact that you received this link from: \(link)"), + primaryButton: .default(Text("Connect")) { + DispatchQueue.main.async { + do { + try apiConnect(connReq: link) + connectionReqSentAlert(path == "contact" ? .contact : .invitation) + } catch { + let err = error.localizedDescription + AlertManager.shared.showAlertMsg(title: "Connection error", message: err) + logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)") + } + } + }, + secondaryButton: .cancel() + ) + } else { + return Alert(title: Text("Error: URL is invalid")) + } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 741b55e9a2..0d7d2608ee 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -15,6 +15,7 @@ struct ChatPreviewView: View { var body: some View { let cItem = chat.chatItems.last + let unread = chat.chatStats.unreadCount return HStack(spacing: 8) { ZStack(alignment: .bottomLeading) { ChatInfoImage(chat: chat) @@ -35,21 +36,37 @@ struct ChatPreviewView: View { Text(chat.chatInfo.chatViewName) .font(.title3) .fontWeight(.bold) + .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - Text(getDateFormatter().string(from: cItem?.meta.itemTs ?? chat.chatInfo.createdAt)) + Text(cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt)) .font(.subheadline) .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) + .padding(.top, 4) + } .padding(.top, 4) .padding(.horizontal, 8) if let cItem = cItem { - Text(chatItemText(cItem)) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) - .padding([.leading, .trailing], 8) - .padding(.bottom, 4) + ZStack(alignment: .topTrailing) { + (itemStatusMark(cItem) + Text(chatItemText(cItem))) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) + .padding(.leading, 8) + .padding(.trailing, 36) + .padding(.bottom, 4) + if unread > 0 { + Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)") + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(Color.accentColor) + .cornerRadius(10) + } + } + .padding(.trailing, 8) } else if case let .direct(contact) = chat.chatInfo, !contact.ready { Text("Connecting...") @@ -61,6 +78,20 @@ struct ChatPreviewView: View { } } + private func itemStatusMark(_ cItem: ChatItem) -> Text { + switch cItem.meta.itemStatus { + case .sndErrorAuth: + return Text(Image(systemName: "multiply")) + .font(.caption) + .foregroundColor(.red) + Text(" ") + case .sndError: + return Text(Image(systemName: "exclamationmark.triangle.fill")) + .font(.caption) + .foregroundColor(.yellow) + Text(" ") + default: return Text("") + } + } + private func chatItemText(_ cItem: ChatItem) -> String { let t = cItem.content.text if case let .groupRcv(groupMember) = cItem.chatDir { @@ -79,11 +110,12 @@ struct ChatPreviewView_Previews: PreviewProvider { )) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)] )) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")] + chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")], + chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) )) } .previewLayout(.fixed(width: 360, height: 78)) diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 2acd47c707..d66af40c6b 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -28,9 +28,9 @@ struct ContactRequestView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - Text(getDateFormatter().string(from: contactRequest.createdAt)) + Text(timestampText(contactRequest.createdAt)) .font(.subheadline) - .padding(.trailing, 28) + .padding(.trailing, 8) .padding(.top, 4) .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) diff --git a/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift b/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift new file mode 100644 index 0000000000..fb12292b6a --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift @@ -0,0 +1,35 @@ +// +// NavLinkPlain.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 11/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct NavLinkPlain: View { + @State var tag: V + @Binding var selection: V? + @ViewBuilder var destination: () -> Destination + @ViewBuilder var label: () -> Label + var disabled = false + + var body: some View { + ZStack { + Button("") { selection = tag } + .disabled(disabled) + label() + } + .background { + NavigationLink("", tag: tag, selection: $selection, destination: destination) + .hidden() + } + } +} + +//struct NavLinkPlain_Previews: PreviewProvider { +// static var previews: some View { +// NavLinkPlain() +// } +//} diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift new file mode 100644 index 0000000000..15883f8340 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -0,0 +1,18 @@ +// +// ShareSheet.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 30/01/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +func showShareSheet(items: [Any]) { + let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene + if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first, + let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController { + let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) + presentedViewController.present(activityViewController, animated: true) + } +} diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 0f0b1521c1..f6d62deb26 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -11,7 +11,6 @@ import CoreImage.CIFilterBuiltins struct AddContactView: View { var connReqInvitation: String - @State private var shareInvitation = false var body: some View { VStack { @@ -27,11 +26,12 @@ struct AddContactView: View { .font(.subheadline) .multilineTextAlignment(.center) .padding(.horizontal) - Button { shareInvitation = true } label: { - Label("Share", systemImage: "square.and.arrow.up") + Button { + showShareSheet(items: [connReqInvitation]) + } label: { + Label("Share invitation link", systemImage: "square.and.arrow.up") } .padding() - .shareSheet(isPresented: $shareInvitation, items: [connReqInvitation]) } } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 0064ac9292..b389f9c47a 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -11,12 +11,8 @@ import SwiftUI struct NewChatButton: View { @State private var showAddChat = false @State private var addContact = false - @State private var addContactAlert = false - @State private var addContactError: Error? @State private var connReqInvitation: String = "" @State private var connectContact = false - @State private var connectAlert = false - @State private var connectError: Error? @State private var createGroup = false var body: some View { @@ -32,15 +28,9 @@ struct NewChatButton: View { .sheet(isPresented: $addContact, content: { AddContactView(connReqInvitation: connReqInvitation) }) - .alert(isPresented: $addContactAlert) { - connectionError(addContactError) - } .sheet(isPresented: $connectContact, content: { connectContactSheet() }) - .alert(isPresented: $connectAlert) { - connectionError(connectError) - } .sheet(isPresented: $createGroup, content: { CreateGroupView() }) } @@ -49,8 +39,9 @@ struct NewChatButton: View { connReqInvitation = try apiAddContact() addContact = true } catch { - addContactAlert = true - addContactError = error + DispatchQueue.global().async { + connectionErrorAlert(error) + } logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") } } @@ -58,21 +49,36 @@ struct NewChatButton: View { func connectContactSheet() -> some View { ConnectContactView(completed: { err in connectContact = false - if err != nil { - connectAlert = true - connectError = err + DispatchQueue.global().async { + if let error = err { + connectionErrorAlert(error) + } else { + connectionReqSentAlert(.invitation) + } } }) } - func connectionError(_ error: Error?) -> Alert { - Alert( - title: Text("Connection error"), - message: Text(error?.localizedDescription ?? "") - ) + func connectionErrorAlert(_ error: Error) { + AlertManager.shared.showAlertMsg(title: "Connection error", message: error.localizedDescription) } } +enum ConnReqType: Equatable { + case contact + case invitation +} + +func connectionReqSentAlert(_ type: ConnReqType) { + let whenConnected = type == .contact + ? "your connection request is accepted" + : "your contact's device is online" + AlertManager.shared.showAlertMsg( + title: "Connection request sent!", + message: "You will be connected when \(whenConnected), please wait or check later!" + ) +} + struct NewChatButton_Previews: PreviewProvider { static var previews: some View { NewChatButton() diff --git a/apps/ios/Shared/Views/NewChat/ShareSheet.swift b/apps/ios/Shared/Views/NewChat/ShareSheet.swift deleted file mode 100644 index 3b9dbcb5e1..0000000000 --- a/apps/ios/Shared/Views/NewChat/ShareSheet.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// ShareSheet.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 30/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -extension UIApplication { - static let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first - static let keyWindowScene = shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene -} - -extension View { - func shareSheet(isPresented: Binding, items: [Any]) -> some View { - guard isPresented.wrappedValue else { return self } - let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) - let presentedViewController = UIApplication.keyWindow?.rootViewController?.presentedViewController ?? UIApplication.keyWindow?.rootViewController - activityViewController.completionWithItemsHandler = { _, _, _, _ in isPresented.wrappedValue = false } - presentedViewController?.present(activityViewController, animated: true) - return self - } -} - -struct ShareSheetTest: View { - @State private var isPresentingShareSheet = false - - var body: some View { - Button("Show Share Sheet") { isPresentingShareSheet = true } - .shareSheet(isPresented: $isPresentingShareSheet, items: ["Share me!"]) - } -} - -struct ShareSheetTest_Previews: PreviewProvider { - static var previews: some View { - ShareSheetTest() - } -} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 74015c2def..9d561a9c26 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -8,6 +8,8 @@ import SwiftUI +private let terminalFont = Font.custom("Menlo", size: 16) + struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel @State var inProgress: Bool = false @@ -31,6 +33,7 @@ struct TerminalView: View { Text(item.label) .frame(maxWidth: .infinity, maxHeight: 30, alignment: .leading) } + .font(terminalFont) .padding(.horizontal) } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 35d969e1f0..e48dececc7 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -11,6 +11,7 @@ import SwiftUI let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")! struct SettingsView: View { + @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool @@ -95,7 +96,7 @@ struct SettingsView: View { } } HStack { - Image("github") + Image(colorScheme == .dark ? "github_light" : "github") .resizable() .frame(width: 24, height: 24) .padding(.trailing, 8) diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift index 7d6c8cca65..6ed2d03744 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -10,7 +10,6 @@ import SwiftUI struct UserAddress: View { @EnvironmentObject var chatModel: ChatModel - @State private var shareAddressLink = false @State private var deleteAddressAlert = false var body: some View { @@ -20,13 +19,14 @@ struct UserAddress: View { if let userAdress = chatModel.userAddress { QRCode(uri: userAdress) HStack { - Button { shareAddressLink = true } label: { + Button { + showShareSheet(items: [userAdress]) + } label: { Label("Share link", systemImage: "square.and.arrow.up") } .padding() - .shareSheet(isPresented: $shareAddressLink, items: [userAdress]) - Button { deleteAddressAlert = true } label: { + Button(role: .destructive) { deleteAddressAlert = true } label: { Label("Delete address", systemImage: "trash") } .padding() @@ -44,7 +44,6 @@ struct UserAddress: View { }, secondaryButton: .cancel() ) } - .shareSheet(isPresented: $shareAddressLink, items: [userAdress]) } .frame(maxWidth: .infinity) } else { diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index b8279472cb..f996e93668 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -19,6 +19,8 @@ + ITSAppUsesNonExemptEncryption + UIBackgroundModes fetch diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 752c52f814..4ed34fa537 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -34,6 +34,12 @@ 5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059927B5CD9300BE3227 /* libffi.a */; }; 5C75059F27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */; }; 5C7505A027B5CD9300BE3227 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059B27B5CD9300BE3227 /* libgmpxx.a */; }; + 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; + 5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; + 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; + 5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; + 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; + 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; 5C764E81279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; @@ -127,6 +133,9 @@ 5C75059927B5CD9300BE3227 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a"; sourceTree = ""; }; 5C75059B27B5CD9300BE3227 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; + 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; + 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E7B279C71D4000C6508 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; 5C764E7C279C71DB000C6508 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (iOS)-Bridging-Header.h"; sourceTree = ""; }; @@ -225,6 +234,7 @@ children = ( 5CE4407427ADB657007B033A /* ChatItem */, 5C2E260E27A30FDC00F70299 /* ChatView.swift */, + 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */, 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */, 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, @@ -270,6 +280,8 @@ isa = PBXGroup; children = ( 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */, + 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */, + 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, ); path = Helpers; sourceTree = ""; @@ -348,7 +360,6 @@ 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */, 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */, 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, - 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, ); path = NewChat; sourceTree = ""; @@ -380,6 +391,7 @@ isa = PBXGroup; children = ( 5CE4407527ADB66A007B033A /* TextItemView.swift */, + 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */, 5CE4407827ADB701007B033A /* EmojiItemView.swift */, ); path = ChatItem; @@ -559,6 +571,7 @@ 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, + 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, @@ -570,6 +583,8 @@ 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, + 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, + 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, @@ -599,6 +614,7 @@ 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */, 5C764E81279C7276000C6508 /* dummy.m in Sources */, + 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */, @@ -610,6 +626,8 @@ 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, + 5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */, + 5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */, 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, @@ -781,7 +799,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -801,7 +819,7 @@ LIBRARY_SEARCH_PATHS = ""; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 0.3; + MARKETING_VERSION = 0.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -821,7 +839,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -841,7 +859,7 @@ LIBRARY_SEARCH_PATHS = ""; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 0.3; + MARKETING_VERSION = 0.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; diff --git a/blog/20220214-simplex-chat-ios-public-beta.md b/blog/20220214-simplex-chat-ios-public-beta.md new file mode 100644 index 0000000000..4d3a7ab195 --- /dev/null +++ b/blog/20220214-simplex-chat-ios-public-beta.md @@ -0,0 +1,40 @@ +# SimpleX announces SimpleX Chat public beta for iOS + +**Published:** Feb 14, 2022 + +## Private and secure chat and application platform - [public beta is now available](https://testflight.apple.com/join/DWuT2LQu) for iPhones with iOS 15. + +Our new iPhone app is very basic - right now it only supports text messages and emojis. + +Even though the app is new, it uses the same core code as our terminal app, that was used and stabilized over a long time, and it provides the same level of privacy and security that has been available since the release of v1 a month ago: +- [double-ratchet](https://www.signal.org/docs/specifications/doubleratchet/) E2E encryption. +- separate keys for each contact. +- additional layer of E2E encryption in each message queue (to prevent traffic correlation when multiple queues are used in a conversation - something we plan later this year). +- additional encryption of messages delivered from servers to recipients (also to prevent traffic correlation). + +You can read more details in our recent [v1 announcement](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220112-simplex-chat-v1-released.md). + +## Join our public beta! + +Install the app [via TestFlight](https://testflight.apple.com/join/DWuT2LQu), connect to us (via **Connect to SimpleX team** link in the app) and to a couple of your friends you usually send messages to - and please let us know what you think! + +We would really appreciate any feedback to improve the app and to decide which additional features should be included in our public release in March. + +Should it be: +- images, +- link previews, +- or maybe something else we couldn't think of. + +Please vote on the features you think are the most needed in our [app roadmap](https://app.loopedin.io/simplex). + +## What is SimpleX? + +We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter. + +We aim to provide the best possible protection of messages and metadata. Today there is no messaging application that works without global user identities, so we believe we provide better metadata privacy than alternatives. SimpleX is designed to be truly distributed with no central server, and without any global user identities. This allows for high scalability at low cost, and also makes it virtually impossible to snoop on the network graph. + +The first application built on the platform is Simplex Chat, which is available for terminal (command line in Windows/Mac/Linux) and as iOS public beta - with Android app coming in a few weeks. The platform can easily support a private social network feed and a multitude of other services, which can be developed by the Simplex team or third party developers. + +SimpleX also allows people to host their own servers to have control of their chat data. SimpleX servers are exceptionally lightweight and require a single process with the initial memory footprint of under 20 Mb, which grows as the server adds in-memory queues (even with 10,000 queues it uses less than 50Mb, not accounting for messages). It should be considered though that while self-hosting the servers provides more control, it may reduce meta-data privacy, as it is easier to correlate the traffic of servers with small number of messages coming through. + +Further details on platform objectives and technical design are available [in SimpleX platform overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md). diff --git a/blog/README.md b/blog/README.md index ca0fa58dc2..1b3fb20501 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,11 +1,13 @@ # Blog -Jan 12, 2022. [SimpleX Chat v1 released: the most private and secure chat and application platform](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md) +Feb 14, 2022. [SimpleX Chat: join our public beta for iOS!](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220214-simplex-chat-ios-public-beta.md) -Dec 08, 2021. [SimpleX Chat v0.5 released: the first chat platform that is 100% private by design - no access to your connections graph](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20211208-simplex-chat-v0.5-released.md) +Jan 12, 2022. [SimpleX Chat v1 released: the most private and secure chat and application platform](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220112-simplex-chat-v1-released.md) -Sep 14, 2021. [SimpleX Chat v0.4 released: open-source chat that uses privacy-preserving message routing protocol](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20210914-simplex-chat-v0.4-released.md) +Dec 08, 2021. [SimpleX Chat v0.5 released: the first chat platform that is 100% private by design - no access to your connections graph](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20211208-simplex-chat-v0.5-released.md) -May 12, 2021. [SimpleX Chat Prototype!](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20210512-simplex-chat-terminal-ui.md) +Sep 14, 2021. [SimpleX Chat v0.4 released: open-source chat that uses privacy-preserving message routing protocol](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20210914-simplex-chat-v0.4-released.md) -Oct 22, 2020. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20201022-simplex-chat) +May 12, 2021. [SimpleX Chat Prototype!](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20210512-simplex-chat-terminal-ui.md) + +Oct 22, 2020. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20201022-simplex-chat) diff --git a/cabal.project b/cabal.project index 1727e1efd6..5df8a3dfe2 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: git://github.com/simplex-chat/simplexmq.git - tag: c380c795600b887fcae1614a52fb5cda691b569d + tag: 229e2607d76f3d6baf0d2623b186c084e3908b8f source-repository-package type: git diff --git a/images/simplex-chat-logo.svg b/images/simplex-chat-logo.svg index 45cae4ff38..31f954e3ab 100644 --- a/images/simplex-chat-logo.svg +++ b/images/simplex-chat-logo.svg @@ -1,5 +1,5 @@ - + @@ -9,21 +9,21 @@ - - + + - - - + + + - + - + diff --git a/package.yaml b/package.yaml index d3f458ebd0..746adde477 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.1.1 +version: 1.2.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/rfcs/2022-02-10-deduplicate-contact-requests.md b/rfcs/2022-02-10-deduplicate-contact-requests.md new file mode 100644 index 0000000000..3e513296d9 --- /dev/null +++ b/rfcs/2022-02-10-deduplicate-contact-requests.md @@ -0,0 +1,19 @@ +# Deduplicate contact requests + +1. add nullable fields `via_contact_uri_hash` and `xcontact_id` to `connections` +2. when joining (Connect -> SCMContact) + - generate and save random `xcontact_id` + - save hash of `AConnectionRequestUri` when joining via contact uri + (AConnectionRequestUri -> ConnectionRequestUri -> CRContactUri) + - send random identifier in `XContact` as `Maybe XContactId` + - check for repeat join - if connection with such `via_contact_uri_hash` has contact notify user + - check for repeat join - check in connections if such contact uri exists, if yes use same identifier; the rest of request can (should) be regenerated, e.g. new server, profile + can be required +3. add nullable field `xcontact_id` to `contact_requests` and to `contacts` (* for auto-acceptance) +4. on contact request (processUserContactRequest) + - save identifier + - \* check if `xcontact_id` is in `contacts` - then notify this contact already exists + - when saving check if contact request with such identifier exists, if yes update `contact_request` + (`invId`, new profile) + - ? remove old invitation - probably not necessarily, to be done in scope of connection expiration + - return from Store whether request is new or updated (Bool?), new chat response for update or same response diff --git a/sha256map.nix b/sha256map.nix index 3d0a905a3e..c6cbc22b77 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "git://github.com/simplex-chat/simplexmq.git"."c380c795600b887fcae1614a52fb5cda691b569d" = "0632zslrv8agvqrzzclb85jm4vdp8hwkvanh65jcd8j28nqsxlzh"; + "git://github.com/simplex-chat/simplexmq.git"."229e2607d76f3d6baf0d2623b186c084e3908b8f" = "1w7mrrsyjh2wskqgdp8ml33xivzzqhzig217q3f92nkzc87ziq4g"; "git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 72c24eea3f..7f665785e3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.1.1 +version: 1.2.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -27,6 +27,7 @@ library Simplex.Chat.Migrations.M20220101_initial Simplex.Chat.Migrations.M20220122_v1_1 Simplex.Chat.Migrations.M20220205_chat_item_status + Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ff7932c709..715e460c87 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -179,13 +179,9 @@ processChatCommand = \case gs -> throwChatError $ CEContactGroups ct gs CTGroup -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not supported" - APIAcceptContact connReqId -> withUser $ \User {userId, profile} -> do - UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p} <- withStore $ \st -> - getContactRequest st userId connReqId - withChatLock . procCmd $ do - connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile - acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p - pure $ CRAcceptingContactRequest acceptedContact + APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do + cReq <- withStore $ \st -> getContactRequest st userId connReqId + procCmd $ CRAcceptingContactRequest <$> acceptContactRequest user cReq APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \st -> @@ -200,15 +196,14 @@ processChatCommand = \case withStore $ \st -> createDirectConnection st userId connId pure $ CRInvitation cReq Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do - connect userId cReq $ XInfo profile + connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile + withStore $ \st -> createDirectConnection st userId connId pure CRSentConfirmation - Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do - connect userId cReq $ XContact profile Nothing - pure CRSentInvitation + Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> + connectViaContact userId cReq profile Connect Nothing -> throwChatError CEInvalidConnReq - ConnectAdmin -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do - connect userId adminContactReq $ XContact profile Nothing - pure CRSentInvitation + ConnectAdmin -> withUser $ \User {userId, profile} -> + connectViaContact userId adminContactReq profile DeleteContact cName -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName processChatCommand $ APIDeleteChat CTDirect contactId @@ -224,7 +219,10 @@ processChatCommand = \case deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteUserContactLink st userId pure CRUserContactLinkDeleted - ShowMyAddress -> CRUserContactLink <$> withUser (\User {userId} -> withStore (`getUserContactLink` userId)) + ShowMyAddress -> withUser $ \User {userId} -> + uncurry CRUserContactLink <$> withStore (`getUserContactLink` userId) + AddressAutoAccept onOff -> withUser $ \User {userId} -> do + uncurry CRUserContactLinkUpdated <$> withStore (\st -> updateUserContactLinkAutoAccept st userId onOff) AcceptContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName processChatCommand $ APIAcceptContact connReqId @@ -247,7 +245,7 @@ processChatCommand = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo) unless (memberActive membership) $ throwChatError CEGroupMemberNotActive let sendInvitation memberId cReq = do - void . sendDirectMessage (contactConn contact) $ + void . sendDirectContactMessage contact $ XGrpInv $ GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile setActive $ ActiveG gName pure $ CRSentGroupInvitation gInfo contact @@ -374,10 +372,10 @@ processChatCommand = \case asks currentUser >>= atomically . (`writeTVar` Just user') contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do - forM_ contacts $ \ct -> sendDirectMessage (contactConn ct) $ XInfo p + forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p pure $ CRUserProfileUpdated profile p QuitChat -> liftIO exitSuccess - ShowVersion -> pure CRVersionInfo + ShowVersion -> pure $ CRVersionInfo versionNumber where withChatLock action = do ChatController {chatLock = l, smpAgent = a} <- ask @@ -395,10 +393,17 @@ processChatCommand = \case -- use function below to make commands "synchronous" -- procCmd :: m ChatResponse -> m ChatResponse -- procCmd = id - connect :: UserId -> ConnectionRequestUri c -> ChatMsgEvent -> m () - connect userId cReq msg = do - connId <- withAgent $ \a -> joinConnection a cReq $ directMessage msg - withStore $ \st -> createDirectConnection st userId connId + connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse + connectViaContact userId cReq profile = withChatLock $ do + let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + withStore (\st -> getConnReqContactXContactId st userId cReqHash) >>= \case + (Just contact, _) -> pure $ CRContactAlreadyExists contact + (_, xContactId_) -> procCmd $ do + let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + xContactId <- maybe randomXContactId pure xContactId_ + connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) + withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId + pure CRSentInvitation contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -439,6 +444,11 @@ processChatCommand = \case f = filePath `combine` (name <> suffix <> ext) in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) +acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact +acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} = do + connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile + withStore $ \st -> createAcceptedContact st userId connId cName profileId p xContactId + agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () agentSubscriber user = do q <- asks $ subQ . smpAgent @@ -538,12 +548,6 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage isMember memId GroupInfo {membership} members = sameMemberId memId membership || isJust (find (sameMemberId memId) members) - contactIsReady :: Contact -> Bool - contactIsReady Contact {activeConn} = connStatus activeConn == ConnReady - - memberIsReady :: GroupMember -> Bool - memberIsReady GroupMember {activeConn} = maybe False ((== ConnReady) . connStatus) activeConn - agentMsgConnStatus :: ACommand 'Agent -> Maybe ConnStatus agentMsgConnStatus = \case CONF {} -> Just ConnRequested @@ -612,8 +616,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRContactConnected ct setActive $ ActiveC c showToast (c <> "> ") "connected" - Just (gInfo, m) -> do - when (memberIsReady m) $ do + Just (gInfo, m@GroupMember {activeConn}) -> do + when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct SENT msgId -> do @@ -707,8 +711,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage Nothing -> do notifyMemberConnected gInfo m messageError "implementation error: connected member does not have contact" - Just ct -> - when (contactIsReady ct) $ do + Just ct@Contact {activeConn = Connection {connStatus}} -> + when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct MSG msgMeta msgBody -> do @@ -812,8 +816,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage REQ invId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of - XContact p _ -> profileContactRequest invId p - XInfo p -> profileContactRequest invId p + XContact p xContactId_ -> profileContactRequest invId p xContactId_ + XInfo p -> profileContactRequest invId p Nothing -- TODO show/log error, other events in contact request _ -> pure () -- TODO print errors @@ -822,11 +826,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> Profile -> m () - profileContactRequest invId p = do - cReq@UserContactRequest {localDisplayName} <- withStore $ \st -> createContactRequest st userId userContactLinkId invId p - toView $ CRReceivedContactRequest cReq - showToast (localDisplayName <> "> ") "wants to connect to you" + profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m () + profileContactRequest invId p xContactId_ = do + withStore (\st -> createOrUpdateContactRequest st userId userContactLinkId invId p xContactId_) >>= \case + Left contact -> toView $ CRContactRequestAlreadyAccepted contact + Right cReq@UserContactRequest {localDisplayName} -> do + (_, autoAccept) <- withStore $ \st -> getUserContactLink st userId + if autoAccept + then acceptContactRequest user cReq >>= toView . CRAcceptingContactRequest + else do + toView $ CRReceivedContactRequest cReq + showToast (localDisplayName <> "> ") "wants to connect to you" withAckMessage :: ConnId -> MsgMeta -> m () -> m () withAckMessage cId MsgMeta {recipient = (msgId, _)} action = @@ -863,14 +873,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage probeMatchingContacts ct = do gVar <- asks idsDrg (probe, probeId) <- withStore $ \st -> createSentProbe st gVar userId ct - void . sendDirectMessage (contactConn ct) $ XInfoProbe probe + void . sendDirectContactMessage ct $ XInfoProbe probe cs <- withStore (\st -> getMatchingContacts st userId ct) let probeHash = ProbeHash $ C.sha256Hash (unProbe probe) forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ()) where sendProbeHash :: Contact -> ProbeHash -> Int64 -> m () sendProbeHash c probeHash probeId = do - void . sendDirectMessage (contactConn c) $ XInfoProbeCheck probeHash + void . sendDirectContactMessage c $ XInfoProbeCheck probeHash withStore $ \st -> createSentProbeHash st userId probeId c messageWarning :: Text -> m () @@ -951,7 +961,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage probeMatch :: Contact -> Contact -> Probe -> m () probeMatch c1@Contact {profile = p1} c2@Contact {profile = p2} probe = when (p1 == p2) $ do - void . sendDirectMessage (contactConn c1) $ XInfoProbeOk probe + void . sendDirectContactMessage c1 $ XInfoProbeOk probe mergeContacts c1 c2 xInfoProbeOk :: Contact -> Probe -> m () @@ -1181,10 +1191,16 @@ throwChatError = throwError . ChatError deleteMemberConnection :: ChatMonad m => GroupMember -> m () deleteMemberConnection m@GroupMember {activeConn} = do -- User {userId} <- asks currentUser - withAgent $ forM_ (memberConnId m) . suspendConnection + withAgent (forM_ (memberConnId m) . deleteConnection) `catchError` const (pure ()) -- withStore $ \st -> deleteGroupMemberConnection st userId m forM_ activeConn $ \conn -> withStore $ \st -> updateConnectionStatus st conn ConnDeleted +sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m MessageId +sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connStatus}} chatMsgEvent = do + if connStatus == ConnReady || connStatus == ConnSndReady + then sendDirectMessage conn chatMsgEvent + else throwChatError $ CEContactNotReady ct + sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> m MessageId sendDirectMessage conn chatMsgEvent = do (msgId, msgBody) <- createSndMessage chatMsgEvent @@ -1251,8 +1267,8 @@ saveRcvMSG Connection {connId} agentMsgMeta msgBody = do pure (msgId, chatMsgEvent) sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd) -sendDirectChatItem userId contact@Contact {activeConn} chatMsgEvent ciContent = do - msgId <- sendDirectMessage activeConn chatMsgEvent +sendDirectChatItem userId contact chatMsgEvent ciContent = do + msgId <- sendDirectContactMessage contact chatMsgEvent createdAt <- liftIO getCurrentTime ciMeta <- saveChatItem userId (CDDirectSnd contact) $ mkNewChatItem ciContent msgId createdAt createdAt pure $ ChatItem CIDirectSnd ciMeta ciContent @@ -1432,6 +1448,7 @@ chatCommandP = <|> ("/address" <|> "/ad") $> CreateMyAddress <|> ("/delete_address" <|> "/da") $> DeleteMyAddress <|> ("/show_address" <|> "/sa") $> ShowMyAddress + <|> "/auto_accept " *> (AddressAutoAccept <$> onOffP) <|> ("/accept @" <|> "/accept " <|> "/ac @" <|> "/ac ") *> (AcceptContact <$> displayName) <|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName) <|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown @@ -1449,6 +1466,7 @@ chatCommandP = msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) refChar c = c > ' ' && c /= '#' && c /= '@' + onOffP = ("on" $> True) <|> ("off" $> False) userProfile = do cName <- displayName fullName <- fullNameP cName diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4bf983d20c..7eec8ee084 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -36,7 +36,7 @@ import System.IO (Handle) import UnliftIO.STM versionNumber :: String -versionNumber = "1.1.1" +versionNumber = "1.2.0" versionStr :: String versionStr = "SimpleX Chat v" <> versionNumber @@ -103,6 +103,7 @@ data ChatCommand | CreateMyAddress | DeleteMyAddress | ShowMyAddress + | AddressAutoAccept Bool | AcceptContact ContactName | RejectContact ContactName | SendMessage ContactName ByteString @@ -142,7 +143,8 @@ data ChatResponse | CRGroupCreated {groupInfo :: GroupInfo} | CRGroupMembers {group :: Group} | CRContactsList {contacts :: [Contact]} - | CRUserContactLink {connReqContact :: ConnReqContact} + | CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool} + | CRUserContactLinkUpdated {connReqContact :: ConnReqContact, autoAccept :: Bool} | CRContactRequestRejected {contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {groupInfo :: GroupInfo} | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} @@ -151,7 +153,7 @@ data ChatResponse | CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRUserProfile {profile :: Profile} | CRUserProfileNoChange - | CRVersionInfo + | CRVersionInfo {version :: String} | CRInvitation {connReqInvitation :: ConnReqInvitation} | CRSentConfirmation | CRSentInvitation @@ -162,6 +164,8 @@ data ChatResponse | CRUserContactLinkDeleted | CRReceivedContactRequest {contactRequest :: UserContactRequest} | CRAcceptingContactRequest {contact :: Contact} + | CRContactAlreadyExists {contact :: Contact} + | CRContactRequestAlreadyAccepted {contact :: Contact} | CRLeftMemberUser {groupInfo :: GroupInfo} | CRGroupDeletedUser {groupInfo :: GroupInfo} | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} @@ -225,6 +229,7 @@ data ChatErrorType | CEChatNotStarted | CEInvalidConnReq | CEInvalidChatMessage {message :: String} + | CEContactNotReady {contact :: Contact} | CEContactGroups {contact :: Contact, groupNames :: [GroupName]} | CEGroupUserRole | CEGroupContactRole {contactName :: ContactName} diff --git a/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs b/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs new file mode 100644 index 0000000000..e2c26e35e0 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220210_deduplicate_contact_requests :: Query +m20220210_deduplicate_contact_requests = + [sql| +-- hash of contact address uri used by contact request sender to connect, +-- null for contact request recipient and for both parties when using one-off invitation +ALTER TABLE connections ADD COLUMN via_contact_uri_hash BLOB; +CREATE INDEX idx_connections_via_contact_uri_hash ON connections (via_contact_uri_hash); + +ALTER TABLE connections ADD COLUMN xcontact_id BLOB; + +ALTER TABLE contact_requests ADD COLUMN xcontact_id BLOB; +CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests (xcontact_id); + +ALTER TABLE contacts ADD COLUMN xcontact_id BLOB; +CREATE INDEX idx_contacts_xcontact_id ON contacts (xcontact_id); + +ALTER TABLE user_contact_links ADD column auto_accept INTEGER DEFAULT 0; +|] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3b49f3ad7d..c1d35e3079 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -70,7 +70,7 @@ data ChatMsgEvent | XFile FileInvitation | XFileAcpt String | XInfo Profile - | XContact Profile (Maybe MsgContent) + | XContact Profile (Maybe XContactId) | XGrpInv GroupInvitation | XGrpAcpt MemberId | XGrpMemNew MemberInfo @@ -264,7 +264,7 @@ appToChatMessage AppMessage {event, params} = do XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" XInfo_ -> XInfo <$> p "profile" - XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "content") params + XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "contactReqId") params XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" @@ -292,8 +292,8 @@ chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params} XMsgNew content -> o ["content" .= content] XFile fileInv -> o ["file" .= fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] - XInfo profile -> o ["profile" .= profile] - XContact profile content -> o $ maybe id ((:) . ("content" .=)) content ["profile" .= profile] + XInfo profile -> o $ ["profile" .= profile] + XContact profile xContactId -> o $ maybe id ((:) . ("contactReqId" .=)) xContactId ["profile" .= profile] XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 2d625d7ebb..757b639407 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -25,6 +25,8 @@ module Simplex.Chat.Store getUsers, setActiveUser, createDirectConnection, + createConnReqConnection, + getConnReqContactXContactId, createDirectContact, getContactGroupNames, deleteContact, @@ -38,7 +40,8 @@ module Simplex.Chat.Store getUserContactLinkConnections, deleteUserContactLink, getUserContactLink, - createContactRequest, + updateUserContactLinkAutoAccept, + createOrUpdateContactRequest, getContactRequest, getContactRequestIdByName, deleteContactRequest, @@ -153,6 +156,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Migrations.M20220101_initial import Simplex.Chat.Migrations.M20220122_v1_1 import Simplex.Chat.Migrations.M20220205_chat_item_status +import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) @@ -169,7 +173,8 @@ schemaMigrations :: [(String, Query)] schemaMigrations = [ ("20220101_initial", m20220101_initial), ("20220122_v1_1", m20220122_v1_1), - ("20220205_chat_item_status", m20220205_chat_item_status) + ("20220205_chat_item_status", m20220205_chat_item_status), + ("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests) ] -- | The list of migrations in ascending order by date @@ -247,6 +252,55 @@ setActiveUser st userId = do DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) +createConnReqConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> m () +createConnReqConnection st userId acId cReqHash xContactId = do + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, + created_at, updated_at, via_contact_uri_hash, xcontact_id + ) VALUES (?,?,?,?,?,?,?,?) + |] + (userId, acId, ConnNew, ConnContact, currentTs, currentTs, cReqHash, xContactId) + +getConnReqContactXContactId :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnReqUriHash -> m (Maybe Contact, Maybe XContactId) +getConnReqContactXContactId st userId cReqHash = do + liftIO . withTransaction st $ \db -> + getContact' db >>= \case + c@(Just _) -> pure (c, Nothing) + Nothing -> (Nothing,) <$> getXContactId db + where + getContact' :: DB.Connection -> IO (Maybe Contact) + getContact' db = + fmap toContact . listToMaybe + <$> DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? + ORDER BY c.connection_id DESC + LIMIT 1 + |] + (userId, cReqHash) + getXContactId :: DB.Connection -> IO (Maybe XContactId) + getXContactId db = + fmap fromOnly . listToMaybe + <$> DB.query + db + "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" + (userId, cReqHash) + createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> m () createDirectConnection st userId agentConnId = liftIO . withTransaction st $ \db -> do @@ -254,7 +308,7 @@ createDirectConnection st userId agentConnId = void $ createContactConnection_ db userId agentConnId Nothing 0 currentTs createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createContactConnection_ db userId = do createConnection_ db userId ConnContact Nothing +createContactConnection_ db userId = createConnection_ db userId ConnContact Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection createConnection_ db userId connType entityId acId viaContact connLevel currentTs = do @@ -502,45 +556,154 @@ deleteUserContactLink st userId = [":user_id" := userId] DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND local_display_name = ''" (Only userId) -getUserContactLink :: StoreMonad m => SQLiteStore -> UserId -> m ConnReqContact +getUserContactLink :: StoreMonad m => SQLiteStore -> UserId -> m (ConnReqContact, Bool) getUserContactLink st userId = liftIOEither . withTransaction st $ \db -> - connReq - <$> DB.query + getUserContactLink_ db userId + +getUserContactLink_ :: DB.Connection -> UserId -> IO (Either StoreError (ConnReqContact, Bool)) +getUserContactLink_ db userId = + firstRow id SEUserContactLinkNotFound $ + DB.query + db + [sql| + SELECT conn_req_contact, auto_accept + FROM user_contact_links + WHERE user_id = ? + AND local_display_name = '' + |] + (Only userId) + +updateUserContactLinkAutoAccept :: StoreMonad m => SQLiteStore -> UserId -> Bool -> m (ConnReqContact, Bool) +updateUserContactLinkAutoAccept st userId autoAccept = do + liftIOEither . withTransaction st $ \db -> runExceptT $ do + (cReqUri, _) <- ExceptT $ getUserContactLink_ db userId + liftIO $ updateUserContactLinkAutoAccept_ db + pure (cReqUri, autoAccept) + where + updateUserContactLinkAutoAccept_ :: DB.Connection -> IO () + updateUserContactLinkAutoAccept_ db = + DB.execute db [sql| - SELECT conn_req_contact - FROM user_contact_links + UPDATE user_contact_links + SET auto_accept = ? WHERE user_id = ? AND local_display_name = '' |] - (Only userId) - where - connReq [Only cReq] = Right cReq - connReq _ = Left SEUserContactLinkNotFound + (autoAccept, userId) -createContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> m UserContactRequest -createContactRequest st userId userContactId invId Profile {displayName, fullName} = +createOrUpdateContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> m (Either Contact UserContactRequest) +createOrUpdateContactRequest st userId userContactLinkId invId profile xContactId_ = liftIOEither . withTransaction st $ \db -> - join <$> withLocalDisplayName db userId displayName (createContactRequest' db) + createOrUpdateContactRequest_ db userId userContactLinkId invId profile xContactId_ + +createOrUpdateContactRequest_ :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> IO (Either StoreError (Either Contact UserContactRequest)) +createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName} xContactId_ = + maybeM getContact' xContactId_ >>= \case + Just contact -> pure . Right $ Left contact + Nothing -> Right <$$> createOrUpdate_ where - createContactRequest' db ldn = do + maybeM = maybe (pure Nothing) + createOrUpdate_ :: IO (Either StoreError UserContactRequest) + createOrUpdate_ = + maybeM getContactRequest' xContactId_ >>= \case + Nothing -> createContactRequest + Just UserContactRequest {contactRequestId, profile = oldProfile} -> + updateContactRequest contactRequestId oldProfile + createContactRequest :: IO (Either StoreError UserContactRequest) + createContactRequest = do currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" - (displayName, fullName, currentTs, currentTs) - profileId <- insertedRowId db - DB.execute - db - [sql| - INSERT INTO contact_requests - (user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?) - |] - (userContactId, invId, profileId, ldn, userId, currentTs, currentTs) - contactRequestId <- insertedRowId db - getContactRequest_ db userId contactRequestId + join <$> withLocalDisplayName db userId displayName (createContactRequest_ currentTs) + where + createContactRequest_ currentTs ldn = do + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" + (displayName, fullName, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + [sql| + INSERT INTO contact_requests + (user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id) + VALUES (?,?,?,?,?,?,?,?) + |] + (userContactLinkId, invId, profileId, ldn, userId, currentTs, currentTs, xContactId_) + contactRequestId <- insertedRowId db + getContactRequest_ db userId contactRequestId + getContact' :: XContactId -> IO (Maybe Contact) + getContact' xContactId = + fmap toContact . listToMaybe + <$> DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? + ORDER BY c.connection_id DESC + LIMIT 1 + |] + (userId, xContactId) + getContactRequest' :: XContactId -> IO (Maybe UserContactRequest) + getContactRequest' xContactId = + fmap toContactRequest . listToMaybe + <$> DB.query + db + [sql| + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? + LIMIT 1 + |] + (userId, xContactId) + updateContactRequest :: Int64 -> Profile -> IO (Either StoreError UserContactRequest) + updateContactRequest cReqId Profile {displayName = oldDisplayName} = do + currentTs <- liftIO getCurrentTime + if displayName == oldDisplayName + then updateContactRequest_ currentTs displayName + else join <$> withLocalDisplayName db userId displayName (updateContactRequest_ currentTs) + where + updateContactRequest_ updatedAt ldn = do + DB.execute + db + [sql| + UPDATE contact_profiles + SET display_name = ?, + full_name = ?, + updated_at = ? + WHERE contact_profile_id IN ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? + AND contact_request_id = ? + ) + |] + (ldn, fullName, updatedAt, userId, cReqId) + DB.execute + db + [sql| + UPDATE contact_requests + SET agent_invitation_id = ?, + local_display_name = ?, + updated_at = ? + WHERE user_id = ? + AND contact_request_id = ? + |] + (invId, ldn, updatedAt, userId, cReqId) + getContactRequest_ db userId cReqId getContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m UserContactRequest getContactRequest st userId contactRequestId = @@ -555,7 +718,7 @@ getContactRequest_ db userId contactRequestId = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -564,12 +727,12 @@ getContactRequest_ db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime, Maybe XContactId) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt) = do +toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt, xContactId) = do let profile = Profile {displayName, fullName} - in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt} + in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt, xContactId} getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 getContactRequestIdByName st userId cName = @@ -592,15 +755,15 @@ deleteContactRequest st userId contactRequestId = (userId, userId, contactRequestId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> m Contact -createAcceptedContact st userId agentConnId localDisplayName profileId profile = +createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> Maybe XContactId -> m Contact +createAcceptedContact st userId agentConnId localDisplayName profileId profile xContactId = liftIO . withTransaction st $ \db -> do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) currentTs <- getCurrentTime DB.execute db - "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (userId, localDisplayName, profileId, currentTs, currentTs) + "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?)" + (userId, localDisplayName, profileId, currentTs, currentTs, xContactId) contactId <- insertedRowId db activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 currentTs pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs} @@ -2148,7 +2311,7 @@ getContactRequestChatPreviews_ db User {userId} = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -2290,9 +2453,18 @@ getContact_ db userId contactId = FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.contact_id = ? AND (c.conn_status = ? OR c.conn_status = ?) - ORDER BY c.connection_id DESC - LIMIT 1 + WHERE ct.user_id = ? AND ct.contact_id = ? + AND c.connection_id = ( + SELECT cc_connection_id FROM ( + SELECT + cc.connection_id AS cc_connection_id, + (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id + ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + LIMIT 1 + ) + ) |] (userId, contactId, ConnReady, ConnSndReady) ) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 96f829f3ce..b342f30771 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -10,7 +10,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TypeApplications #-} {-# LANGUAGE UndecidableInstances #-} module Simplex.Chat.Types where @@ -100,13 +99,52 @@ data UserContactRequest = UserContactRequest localDisplayName :: ContactName, profileId :: Int64, profile :: Profile, - createdAt :: UTCTime + createdAt :: UTCTime, + xContactId :: Maybe XContactId } deriving (Eq, Show, Generic, FromJSON) instance ToJSON UserContactRequest where toEncoding = J.genericToEncoding J.defaultOptions +newtype XContactId = XContactId ByteString + deriving (Eq, Show) + +instance FromField XContactId where fromField f = XContactId <$> fromField f + +instance ToField XContactId where toField (XContactId m) = toField m + +instance StrEncoding XContactId where + strEncode (XContactId m) = strEncode m + strDecode s = XContactId <$> strDecode s + strP = XContactId <$> strP + +instance FromJSON XContactId where + parseJSON = strParseJSON "XContactId" + +instance ToJSON XContactId where + toJSON = strToJSON + toEncoding = strToJEncoding + +newtype ConnReqUriHash = ConnReqUriHash {unConnReqUriHash :: ByteString} + deriving (Eq, Show) + +instance FromField ConnReqUriHash where fromField f = ConnReqUriHash <$> fromField f + +instance ToField ConnReqUriHash where toField (ConnReqUriHash m) = toField m + +instance StrEncoding ConnReqUriHash where + strEncode (ConnReqUriHash m) = strEncode m + strDecode s = ConnReqUriHash <$> strDecode s + strP = ConnReqUriHash <$> strP + +instance FromJSON ConnReqUriHash where + parseJSON = strParseJSON "ConnReqUriHash" + +instance ToJSON ConnReqUriHash where + toJSON = strToJSON + toEncoding = strToJEncoding + type ContactName = Text type GroupName = Text diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ffbf8f1ce2..08ad47543c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -51,7 +51,8 @@ responseToView cmd testView = \case HSMarkdown -> r markdownInfo CRWelcome user -> r $ chatWelcome user CRContactsList cs -> r $ viewContactsList cs - CRUserContactLink cReq -> r $ connReqContact_ "Your chat address:" cReq + CRUserContactLink cReqUri _ -> r $ connReqContact_ "Your chat address:" cReqUri + CRUserContactLinkUpdated _ autoAccept -> r ["auto_accept " <> if autoAccept then "on" else "off"] CRContactRequestRejected UserContactRequest {localDisplayName = c} -> r [ttyContact c <> ": contact request rejected"] CRGroupCreated g -> r $ viewGroupCreated g CRGroupMembers g -> r $ viewGroupMembers g @@ -60,13 +61,15 @@ responseToView cmd testView = \case CRFileTransferStatus ftStatus -> r $ viewFileTransferStatus ftStatus CRUserProfile p -> r $ viewUserProfile p CRUserProfileNoChange -> r ["user profile did not change"] - CRVersionInfo -> r [plain versionStr, plain updateStr] + CRVersionInfo _ -> r [plain versionStr, plain updateStr] CRChatCmdError e -> r $ viewChatError e CRInvitation cReq -> r' $ viewConnReqInvitation cReq CRSentConfirmation -> r' ["confirmation sent!"] CRSentInvitation -> r' ["connection request sent!"] - CRContactDeleted Contact {localDisplayName = c} -> r' [ttyContact c <> ": contact is deleted"] - CRAcceptingContactRequest Contact {localDisplayName = c} -> r' [ttyContact c <> ": accepting contact request..."] + CRContactDeleted c -> r' [ttyContact' c <> ": contact is deleted"] + CRAcceptingContactRequest c -> r' [ttyFullContact c <> ": accepting contact request..."] + CRContactAlreadyExists c -> r [ttyFullContact c <> ": contact already exists"] + CRContactRequestAlreadyAccepted c -> r' [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted CRUserAcceptedGroupSent _g -> r' [] -- [ttyGroup' g <> ": joining the group..."] @@ -471,7 +474,8 @@ viewChatError = \case CEChatNotStarted -> ["error: chat not started"] CEInvalidConnReq -> viewInvalidConnReq CEInvalidChatMessage e -> ["chat message error: " <> sShow e] - CEContactGroups Contact {localDisplayName} gNames -> [ttyContact localDisplayName <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames] + CEContactNotReady c -> [ttyContact' c <> ": not ready"] + CEContactGroups c gNames -> [ttyContact' c <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"] CEGroupUserRole -> ["you have insufficient permissions for this group command"] diff --git a/stack.yaml b/stack.yaml index 4b16aa4f0c..09a567a0bc 100644 --- a/stack.yaml +++ b/stack.yaml @@ -48,7 +48,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: c380c795600b887fcae1614a52fb5cda691b569d + commit: 229e2607d76f3d6baf0d2623b186c084e3908b8f # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index d08f99b983..9e7ecb1404 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -52,6 +52,9 @@ chatTests = do it "send and receive file to group" testGroupFileTransfer describe "user contact link" $ do it "should create and connect via contact link" testUserContactLink + it "should auto accept contact requests" testUserContactLinkAutoAccept + it "should deduplicate contact requests" testDeduplicateContactRequests + it "should deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange it "should reject contact and delete contact link" testRejectContactAndDeleteUserContact it "should delete connection requests when contact link deleted" testDeleteConnectionRequests @@ -700,7 +703,7 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ alice <#? bob alice #$$> ("/_get chats", [("<@bob", "")]) alice ##> "/ac bob" - alice <## "bob: accepting contact request..." + alice <## "bob (Bob): accepting contact request..." concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -711,13 +714,172 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ alice <#? cath alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")]) alice ##> "/ac cath" - alice <## "cath: accepting contact request..." + alice <## "cath (Catherine): accepting contact request..." concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) alice <##> cath +testUserContactLinkAutoAccept :: IO () +testUserContactLinkAutoAccept = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/c " <> cLink) + alice <#? bob + alice #$$> ("/_get chats", [("<@bob", "")]) + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice #$$> ("/_get chats", [("@bob", "")]) + alice <##> bob + + alice ##> "/auto_accept on" + alice <## "auto_accept on" + + cath ##> ("/c " <> cLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice <##> cath + + alice ##> "/auto_accept off" + alice <## "auto_accept off" + + dan ##> ("/c " <> cLink) + alice <#? dan + alice #$$> ("/_get chats", [("<@dan", ""), ("@cath", "hey"), ("@bob", "hey")]) + alice ##> "/ac dan" + alice <## "dan (Daniel): accepting contact request..." + concurrently_ + (dan <## "alice (Alice): contact is connected") + (alice <## "dan (Daniel): contact is connected") + alice #$$> ("/_get chats", [("@dan", ""), ("@cath", "hey"), ("@bob", "hey")]) + alice <##> dan + +testDeduplicateContactRequests :: IO () +testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/c " <> cLink) + alice <#? bob + alice #$$> ("/_get chats", [("<@bob", "")]) + + bob ##> ("/c " <> cLink) + alice <#? bob + bob ##> ("/c " <> cLink) + alice <#? bob + alice #$$> ("/_get chats", [("<@bob", "")]) + + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + bob ##> ("/c " <> cLink) + bob <## "alice (Alice): contact already exists" + alice #$$> ("/_get chats", [("@bob", "")]) + bob #$$> ("/_get chats", [("@alice", "")]) + + alice <##> bob + alice #$$> ("/_get chats", [("@bob", "hey")]) + bob #$$> ("/_get chats", [("@alice", "hey")]) + + bob ##> ("/c " <> cLink) + bob <## "alice (Alice): contact already exists" + + alice <##> bob + alice #$> ("/_get chat @2 count=100", chat, [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) + bob #$> ("/_get chat @2 count=100", chat, [(0, "hi"), (1, "hey"), (0, "hi"), (1, "hey")]) + + cath ##> ("/c " <> cLink) + alice <#? cath + alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")]) + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")]) + alice <##> cath + +testDeduplicateContactRequestsProfileChange :: IO () +testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/c " <> cLink) + alice <#? bob + alice #$$> ("/_get chats", [("<@bob", "")]) + + bob ##> "/p bob" + bob <## "user full name removed (your contacts are notified)" + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + alice <## "bob wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice #$$> ("/_get chats", [("<@bob", "")]) + + bob ##> "/p bob Bob Ross" + bob <## "user full name changed to Bob Ross (your contacts are notified)" + bob ##> ("/c " <> cLink) + alice <#? bob + alice #$$> ("/_get chats", [("<@bob", "")]) + + bob ##> "/p robert Robert" + bob <## "user profile is changed to robert (Robert) (your contacts are notified)" + bob ##> ("/c " <> cLink) + alice <#? bob + alice #$$> ("/_get chats", [("<@robert", "")]) + + alice ##> "/ac bob" + alice <## "no contact request from bob" + alice ##> "/ac robert" + alice <## "robert (Robert): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "robert (Robert): contact is connected") + + bob ##> ("/c " <> cLink) + bob <## "alice (Alice): contact already exists" + alice #$$> ("/_get chats", [("@robert", "")]) + bob #$$> ("/_get chats", [("@alice", "")]) + + alice <##> bob + alice #$$> ("/_get chats", [("@robert", "hey")]) + bob #$$> ("/_get chats", [("@alice", "hey")]) + + bob ##> ("/c " <> cLink) + bob <## "alice (Alice): contact already exists" + + alice <##> bob + alice #$> ("/_get chat @2 count=100", chat, [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) + bob #$> ("/_get chat @2 count=100", chat, [(0, "hi"), (1, "hey"), (0, "hi"), (1, "hey")]) + + cath ##> ("/c " <> cLink) + alice <#? cath + alice #$$> ("/_get chats", [("<@cath", ""), ("@robert", "hey")]) + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice #$$> ("/_get chats", [("@cath", ""), ("@robert", "hey")]) + alice <##> cath + testRejectContactAndDeleteUserContact :: IO () testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 5ba91200f3..9491e1fb68 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -84,15 +84,18 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" #==# XInfo testProfile it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = ""} - it "x.contact without content field" $ + it "x.contact with xContactId" $ + "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" + #==# XContact testProfile (Just $ XContactId "\1\2\3\4") + it "x.contact without XContactId" $ "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" #==# XContact testProfile Nothing it "x.contact with content null" $ "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" ==# XContact testProfile Nothing - it "x.contact with content" $ + it "x.contact with content (ignored)" $ "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" - #==# XContact testProfile (Just $ MCText "hello") + ==# XContact testProfile Nothing it "x.grp.inv" $ "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}