From af5abae55874cb56da5423166d0a545a80b7ec9d Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Sat, 12 Feb 2022 13:17:11 +0400 Subject: [PATCH 01/17] fix group leave (#294) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ff7932c709..a055f9434f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1181,7 +1181,7 @@ 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 From 9d9bb68d50b17e618786413f459f9d30ebaf0e71 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 12 Feb 2022 15:59:43 +0000 Subject: [PATCH 02/17] iOS: show message sent/unread status (#293) * light github image for dark mode * show message received status, remove chevrons in chat list * show unread message status * add message send error mark * refactor alerts to use AlertManager * show alert message on tapping undelivered message, simplify text-only alerts --- .../github.imageset/Contents.json | 6 +- .../github.imageset/github32px.png | Bin 1714 -> 0 bytes .../github.imageset/github64px-1.png | Bin 2625 -> 0 bytes .../github.imageset/github64px.png | Bin 2625 -> 0 bytes .../github.imageset/github_1x.png | Bin 0 -> 2604 bytes .../github.imageset/github_2x.png | Bin 0 -> 4555 bytes .../github.imageset/github_3x.png | Bin 0 -> 5971 bytes .../github_light.imageset/Contents.json | 23 +++ .../github_light.imageset/github_light_1x.png | Bin 0 -> 2145 bytes .../github_light.imageset/github_light_2x.png | Bin 0 -> 3528 bytes .../github_light.imageset/github_light_3x.png | Bin 0 -> 4745 bytes apps/ios/Shared/ContentView.swift | 49 +++-- apps/ios/Shared/Model/ChatModel.swift | 168 +++++++++++++++--- apps/ios/Shared/Model/SimpleXAPI.swift | 77 +++++++- apps/ios/Shared/SimpleXApp.swift | 1 - .../Shared/Views/Chat/ChatInfoToolbar.swift | 43 +++++ apps/ios/Shared/Views/Chat/ChatInfoView.swift | 39 ++-- .../Views/Chat/ChatItem/CIMetaView.swift | 47 +++++ .../Views/Chat/ChatItem/EmojiItemView.swift | 8 +- .../Views/Chat/ChatItem/TextItemView.swift | 45 ++--- apps/ios/Shared/Views/Chat/ChatView.swift | 56 +++--- .../Views/ChatList/ChatListNavLink.swift | 73 ++++---- .../Shared/Views/ChatList/ChatListView.swift | 65 ++++--- .../Views/ChatList/ChatPreviewView.swift | 45 ++++- .../Views/ChatList/ContactRequestView.swift | 4 +- .../Shared/Views/Helpers/NavLinkPlain.swift | 35 ++++ .../ios/Shared/Views/Helpers/ShareSheet.swift | 18 ++ .../Shared/Views/NewChat/AddContactView.swift | 8 +- .../Shared/Views/NewChat/NewChatButton.swift | 25 +-- .../ios/Shared/Views/NewChat/ShareSheet.swift | 40 ----- apps/ios/Shared/Views/TerminalView.swift | 3 + .../Views/UserSettings/SettingsView.swift | 3 +- .../Views/UserSettings/UserAddress.swift | 9 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++- 34 files changed, 635 insertions(+), 275 deletions(-) delete mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github32px.png delete mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github64px-1.png delete mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github64px.png create mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github_1x.png create mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github_2x.png create mode 100644 apps/ios/Shared/Assets.xcassets/github.imageset/github_3x.png create mode 100644 apps/ios/Shared/Assets.xcassets/github_light.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_1x.png create mode 100644 apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_2x.png create mode 100644 apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_3x.png create mode 100644 apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift create mode 100644 apps/ios/Shared/Views/Helpers/NavLinkPlain.swift create mode 100644 apps/ios/Shared/Views/Helpers/ShareSheet.swift delete mode 100644 apps/ios/Shared/Views/NewChat/ShareSheet.swift 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 8b25551a97921681334176ee143b41510a117d86..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1714 zcmaJ?X;2eq7*4oFu!ne{XxAht2qc?8LXr|_LPCfTpaBK7K$c{I0Ld=NLIOeuC;@2) zZ$K%a)k+m-s0>xHmKxL%0V&0TRzzznhgyqrIC$F)0{WwLXLrBvd*^wc_uSc%h%m9E z{W5z3f#4_!7RvAyFh6!S_*<8qJ%KOIm?#E|L=rJQq=gB5C6WLG5;c?r%V0>EmEH#X z5eSwPRa6WXBMs#$5H%GtW2go-in9p>zW@UYDNNWc^XOXZQ? z1QjEV00I#$3^1wQUJ8&-2UsjB-G|9y(LDhMNN3PM{APL4eYi{(m*ERcUnJa{R+-3^ z34^A6;U^v`8N*O6ji%S@sd{fJqD`XFIUJ5zgTe5^5nj414F(y!G&=H(f)Lgzv?>%+ zAsWD}2qhpH7>|TU`X&W6IxDNuO_vET7|j5oG&&VDr!)hUO8+0KR?nh!m<)a!?|%yG zqOwq!CWCcIhE{<$E|F|@g>nP6FoYr6C<8>D?ID9%&5J(4oSbR1I^byW*g@__U z4QsF&uJSEcFeleM3~ChjEQGbHOjsGDMbyAl(p=Ttv9RaVo8~I#js@@Y9C^_2U})yn zzSHU%6FxuY?d;&65MyR({^lU*3$z$ZllDb(o&<7d;A_`h2U+3~BJ2Hv`{W}KEU801#cv_B|9Cm!ynR{S`AMsSn z;7E=B;mb!wx$L;S>yGXG^6=&WlQn9$s?&L%Y1D8TI^MlKB1DqsEng$>f4=xYWBoPI z_S1p!sJ#d2?YI4kPA{k}Eby?F=f-J9zIc`YDl^pzjVm~9ebE?Hn?t0Nx+la|D0MB; z9)2xv1G>a1|A9kQ>~DV<=X3-4yC&n!m8-3K#P z{X@0zRuQsy$+N ziSCoLJU{Z$nQy4A4Y5UJ07$5FA~qL2%Q+cLaqDU?Lz3?=BC5;Nk6BbTmmceEaM>-Z zi>O&-dSE=%ex;vcvCOk{*JQ5^_4M z4lW7%l9IqY(z7pV(?I@@8=KPFO82)O{VDI18-*d-k$YmI^XiuPs_LuFw<^ZcD}yP5 c*NrbeloN*74g`U%%F6r~k%+>C^#XapzmV0H-2eap 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 182a1a3f734fc1b7d712c68b04c29bad9460d6cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2625 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 W4I>>0bz>}E>ge79vm#m=SD{u?K%I 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4cef050214f180ed0a51bda0aca8be24149315a6 GIT binary patch literal 4555 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 ZFCvD7r_yvMWj~&DN>Xo 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a9881b6bca3c1436848426fe402a8fa8aa13be80 GIT binary patch literal 4745 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 literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 0392e7274b..2f39dfa075 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,49 @@ 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) { + 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/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 60daa0871f..db11cdd4b8 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)") } 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..34cc4454c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -14,15 +14,13 @@ struct EmojiItemView: View { var body: some View { let sent = chatItem.chatDir.sent - VStack { + VStack(spacing: 1) { Text(chatItem.content.text.trimmingCharacters(in: .whitespaces)) .font(emojiFont) .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 +33,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).. 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..e6777bd0c0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -10,8 +10,6 @@ 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 @@ -32,6 +30,19 @@ struct ChatListView: View { } ForEach(chatModel.chats) { 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) @@ -45,50 +56,36 @@ 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")) { + 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) } catch { - connectAlert = true - connectError = error - logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(error.localizedDescription)") + let err = error.localizedDescription + AlertManager.shared.showAlertMsg(title: "Connection error", message: err) + logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)") } - chatModel.appOpenUrl = nil - }, secondaryButton: .cancel() { - chatModel.appOpenUrl = nil } - ) - } else { - return Alert(title: Text("Error: URL is invalid")) - } + }, + secondaryButton: .cancel() + ) } else { - return Alert(title: Text("Error: URL not available")) + return Alert(title: Text("Error: URL is invalid")) } } - - private func connectionErrorAlert() -> Alert { - Alert( - title: Text("Connection error"), - message: Text(connectError?.localizedDescription ?? "") - ) - } } struct ChatListView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 741b55e9a2..c82f520bb4 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,36 @@ 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, 32) + .padding(.bottom, 4) + if unread > 0 { + Text(unread > 9 ? "" : "\(unread)") + .font(.caption) + .foregroundColor(.white) + .frame(width: 18, height: 18) + .background(Color.accentColor) //Color(.sRGB, red: 0, green: 0.57, blue: 1, opacity: 0.89)) + .cornerRadius(10) + } + } + .padding(.trailing, 8) } else if case let .direct(contact) = chat.chatInfo, !contact.ready { Text("Connecting...") @@ -61,6 +77,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 +109,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, 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: 3, 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..95add21038 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,7 @@ struct NewChatButton: View { connReqInvitation = try apiAddContact() addContact = true } catch { - addContactAlert = true - addContactError = error + connectionErrorAlert(error) logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") } } @@ -58,18 +47,14 @@ struct NewChatButton: View { func connectContactSheet() -> some View { ConnectContactView(completed: { err in connectContact = false - if err != nil { - connectAlert = true - connectError = err + if let error = err { + connectionErrorAlert(error) } }) } - 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) } } 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.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 752c52f814..08139a2870 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 */, From 067f122b05ecac3aaa3818de5bdbfed185db7117 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 12 Feb 2022 17:28:37 +0000 Subject: [PATCH 03/17] iOS app version 0.3.1 --- apps/ios/SimpleX--iOS--Info.plist | 2 ++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) 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 08139a2870..2ad191b36d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -799,7 +799,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -819,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; @@ -839,7 +839,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -859,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; From aa2bc545db18d710d6644a81d311801da9960b3f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 12 Feb 2022 18:02:52 +0000 Subject: [PATCH 04/17] update build number (8) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2ad191b36d..b52ecdc22f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -799,7 +799,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -839,7 +839,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; From 61afb64dd75b13ae8d697f7fb30292377d7481ad Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 13 Feb 2022 08:45:08 +0000 Subject: [PATCH 05/17] search chats, longer emojis (#295) * search chats, longer emojis * simplify --- .../Views/Chat/ChatItem/EmojiItemView.swift | 5 +++-- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- apps/ios/Shared/Views/Chat/Emoji.swift | 5 +++-- .../Shared/Views/Chat/SendMessageView.swift | 6 +++++- .../Shared/Views/ChatList/ChatListView.swift | 20 +++++++++++++++++-- 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 34cc4454c2..7a4ff3b5f2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -13,10 +13,11 @@ struct EmojiItemView: View { var body: some View { let sent = chatItem.chatDir.sent + let s = chatItem.content.text.trimmingCharacters(in: .whitespaces) VStack(spacing: 1) { - Text(chatItem.content.text.trimmingCharacters(in: .whitespaces)) - .font(emojiFont) + Text(s) + .font(s.count < 4 ? largeEmojiFont : mediumEmojiFont) .padding(.top, 8) .padding(.horizontal, 6) .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 0d7941d40c..cdaa1905da 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -105,7 +105,7 @@ struct ChatView: View { } func markAllRead() { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { if chatModel.chatId == chat.id { markChatRead(chat) } diff --git a/apps/ios/Shared/Views/Chat/Emoji.swift b/apps/ios/Shared/Views/Chat/Emoji.swift index 479336395e..bac1478b24 100644 --- a/apps/ios/Shared/Views/Chat/Emoji.swift +++ b/apps/ios/Shared/Views/Chat/Emoji.swift @@ -24,7 +24,8 @@ func isEmoji(_ c: Character) -> 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/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index e6777bd0c0..e657369126 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -12,11 +12,12 @@ struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel // 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) { @@ -28,7 +29,7 @@ struct ChatListView: View { .padding(.leading) } } - ForEach(chatModel.chats) { chat in + ForEach(filteredChats()) { chat in ChatListNavLink(chat: chat) .padding(.trailing, -16) } @@ -48,6 +49,7 @@ struct ChatListView: View { .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() @@ -58,6 +60,20 @@ struct ChatListView: View { } } .navigationViewStyle(.stack) + + if chatModel.chats.count > 8 { + v.searchable(text: $searchText) + } else { + v + } + } + + 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 { From 8e34d2fbbc165c83eacaa71c60afdfbcee4dbd58 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 13 Feb 2022 09:13:06 +0000 Subject: [PATCH 06/17] fix swift --- apps/ios/Shared/Views/ChatList/ChatListView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index e657369126..19aaec6763 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -73,7 +73,6 @@ struct ChatListView: View { return s == "" ? chatModel.chats : chatModel.chats.filter { $0.chatInfo.chatViewName.localizedLowercase.contains(s) } - } } private func connectViaUrlAlert(_ url: URL) -> Alert { From c1c55ca70032f6b55ae0ea9c07cdb97887d40539 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Sun, 13 Feb 2022 13:19:24 +0400 Subject: [PATCH 07/17] deduplicate contact requests (#287) * deprecate XContact * XInfoId * xInfoId tests * merging * saving on connection * connectByAddress * remove old connect * deduplicate contact requests * check on contact acceptance * test * rename response * reuse CRContactRequestAlreadyAccepted * Update src/Simplex/Chat.hs * createConnReqConnection * simplify controller logic * store methods + profile change * index * more indices * unXInfoId * simplify * XInfo with ID -> XContact * sync reply to Connect when contact already exists * update view for sync CRContactAlreadyExists command response Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .gitignore | 14 +- ...2022-02-10-deduplicate-contact-requests.md | 19 ++ simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 54 +++-- src/Simplex/Chat/Controller.hs | 2 + .../M20220210_deduplicate_contact_requests.hs | 23 ++ src/Simplex/Chat/Protocol.hs | 8 +- src/Simplex/Chat/Store.hs | 204 +++++++++++++++--- src/Simplex/Chat/Types.hs | 42 +++- src/Simplex/Chat/View.hs | 6 +- tests/ChatTests.hs | 121 ++++++++++- tests/ProtocolTests.hs | 9 +- 12 files changed, 426 insertions(+), 77 deletions(-) create mode 100644 rfcs/2022-02-10-deduplicate-contact-requests.md create mode 100644 src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs 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/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/simplex-chat.cabal b/simplex-chat.cabal index 72c24eea3f..f773fc489f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -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 a055f9434f..1130fb5949 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -179,12 +179,12 @@ 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 + APIAcceptContact connReqId -> withUser $ \User {userId, profile} -> withChatLock $ do + UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} <- + withStore $ \st -> getContactRequest st userId connReqId + procCmd $ do connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile - acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p + acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p xContactId pure $ CRAcceptingContactRequest acceptedContact APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- @@ -200,15 +200,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 @@ -395,10 +394,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} -> @@ -812,8 +818,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 +828,13 @@ 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 + toView $ CRReceivedContactRequest cReq + showToast (localDisplayName <> "> ") "wants to connect to you" withAckMessage :: ConnId -> MsgMeta -> m () -> m () withAckMessage cId MsgMeta {recipient = (msgId, _)} action = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4bf983d20c..9fcabae5ec 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -162,6 +162,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} 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..0f55c95537 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs @@ -0,0 +1,23 @@ +{-# 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); +|] 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..b857dd9f69 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,7 @@ module Simplex.Chat.Store getUserContactLinkConnections, deleteUserContactLink, getUserContactLink, - createContactRequest, + createOrUpdateContactRequest, getContactRequest, getContactRequestIdByName, deleteContactRequest, @@ -153,6 +155,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 +172,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 +251,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 +307,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 @@ -519,28 +572,117 @@ getUserContactLink st userId = connReq [Only cReq] = Right cReq connReq _ = Left SEUserContactLinkNotFound -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 +697,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 +706,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 +734,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 +2290,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) 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..800b646d7f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -65,8 +65,10 @@ responseToView cmd testView = \case 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..."] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index d08f99b983..6fbc627aad 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -52,6 +52,8 @@ 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 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 +702,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 +713,128 @@ 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 +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} From 7805bd1e4534553c2563de1580f1e46ee5bc8575 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 13 Feb 2022 10:09:09 +0000 Subject: [PATCH 08/17] show large unread numbers --- .../ios/Shared/Views/ChatList/ChatPreviewView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index c82f520bb4..0d7d2608ee 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -54,14 +54,15 @@ struct ChatPreviewView: View { (itemStatusMark(cItem) + Text(chatItemText(cItem))) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding(.leading, 8) - .padding(.trailing, 32) + .padding(.trailing, 36) .padding(.bottom, 4) if unread > 0 { - Text(unread > 9 ? "" : "\(unread)") + Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)") .font(.caption) .foregroundColor(.white) - .frame(width: 18, height: 18) - .background(Color.accentColor) //Color(.sRGB, red: 0, green: 0.57, blue: 1, opacity: 0.89)) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(Color.accentColor) .cornerRadius(10) } } @@ -113,8 +114,8 @@ struct ChatPreviewView_Previews: PreviewProvider { )) 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.")], - chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) + 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)) From e90520a5ece937b33aef91a6517c49142eaaa09b Mon Sep 17 00:00:00 2001 From: Mark Aleksander Hil <32651095+markaleksanderh@users.noreply.github.com> Date: Mon, 14 Feb 2022 10:29:16 +0000 Subject: [PATCH 09/17] update banner (#297) --- images/simplex-chat-logo.svg | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 @@ - - + + - - - + + + - + - + From dc306dfcd0621decec6b726d688c1bbf62f8cca1 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 14:59:11 +0400 Subject: [PATCH 10/17] option to auto-accept contact requests (#296) --- src/Simplex/Chat.hs | 30 ++++++++----- src/Simplex/Chat/Controller.hs | 4 +- .../M20220210_deduplicate_contact_requests.hs | 2 + src/Simplex/Chat/Store.hs | 39 ++++++++++++---- src/Simplex/Chat/View.hs | 3 +- tests/ChatTests.hs | 45 +++++++++++++++++++ 6 files changed, 102 insertions(+), 21 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1130fb5949..035b1701e5 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} -> withChatLock $ do - UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} <- - withStore $ \st -> getContactRequest st userId connReqId - procCmd $ do - connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile - acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p xContactId - 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 -> @@ -223,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 @@ -445,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 @@ -833,8 +837,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage withStore (\st -> createOrUpdateContactRequest st userId userContactLinkId invId p xContactId_) >>= \case Left contact -> toView $ CRContactRequestAlreadyAccepted contact Right cReq@UserContactRequest {localDisplayName} -> do - toView $ CRReceivedContactRequest cReq - showToast (localDisplayName <> "> ") "wants to connect to you" + (_, 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 = @@ -1440,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 @@ -1457,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 9fcabae5ec..9d3175f1d9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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} diff --git a/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs b/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs index 0f55c95537..e2c26e35e0 100644 --- a/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs +++ b/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs @@ -20,4 +20,6 @@ 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/Store.hs b/src/Simplex/Chat/Store.hs index b857dd9f69..16f1257538 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -40,6 +40,7 @@ module Simplex.Chat.Store getUserContactLinkConnections, deleteUserContactLink, getUserContactLink, + updateUserContactLinkAutoAccept, createOrUpdateContactRequest, getContactRequest, getContactRequestIdByName, @@ -555,22 +556,42 @@ 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) createOrUpdateContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> m (Either Contact UserContactRequest) createOrUpdateContactRequest st userId userContactLinkId invId profile xContactId_ = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 800b646d7f..01e697dc6c 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 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 6fbc627aad..9e7ecb1404 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -52,6 +52,7 @@ 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 @@ -720,6 +721,50 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ 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 From 710971a0cd93351e4cc49026d3980f6af00457ce Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 11:53:44 +0000 Subject: [PATCH 11/17] show confirmation alert after the connection (#299) * show confirmation alert after the connection * update build number --- apps/ios/Shared/ContentView.swift | 1 + apps/ios/Shared/Model/NtfManager.swift | 4 --- .../Shared/Views/ChatList/ChatListView.swift | 1 + .../Shared/Views/NewChat/NewChatButton.swift | 27 ++++++++++++++++--- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 +-- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2f39dfa075..24cd35b187 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -53,6 +53,7 @@ final class AlertManager: ObservableObject { @Published var alertView: Alert? func showAlert(_ alert: Alert) { + logger.debug("AlertManager.showAlert") DispatchQueue.main.async { self.alertView = alert self.presentAlert = true 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/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 19aaec6763..4f5d044716 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -88,6 +88,7 @@ struct ChatListView: View { 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) diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 95add21038..b389f9c47a 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -39,7 +39,9 @@ struct NewChatButton: View { connReqInvitation = try apiAddContact() addContact = true } catch { - connectionErrorAlert(error) + DispatchQueue.global().async { + connectionErrorAlert(error) + } logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)") } } @@ -47,8 +49,12 @@ struct NewChatButton: View { func connectContactSheet() -> some View { ConnectContactView(completed: { err in connectContact = false - if let error = err { - connectionErrorAlert(error) + DispatchQueue.global().async { + if let error = err { + connectionErrorAlert(error) + } else { + connectionReqSentAlert(.invitation) + } } }) } @@ -58,6 +64,21 @@ struct NewChatButton: View { } } +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/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index b52ecdc22f..4ed34fa537 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -799,7 +799,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -839,7 +839,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; From 8cf88019e592c9dc1611bdc2c0ce7983c7a4395a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 13:48:21 +0000 Subject: [PATCH 12/17] ios public beta announcement (#298) * ios public beta announcement * update post * corrections * corrections * update blog links Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> --- README.md | 6 +-- apps/android/.idea/misc.xml | 2 +- blog/20220214-simplex-chat-ios-public-beta.md | 40 +++++++++++++++++++ blog/README.md | 12 +++--- 4 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 blog/20220214-simplex-chat-ios-public-beta.md 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/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) From 44190513479a2b031bdc42829214f9a1478bf2b9 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 18:49:42 +0400 Subject: [PATCH 13/17] connection precedence logic in getContact_ (fixes asynchronous establishment of connection) (#300) --- src/Simplex/Chat.hs | 34 +++++++++++++++++----------------- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Store.hs | 15 ++++++++++++--- src/Simplex/Chat/View.hs | 3 ++- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 035b1701e5..809e3d9cde 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -245,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 @@ -372,7 +372,7 @@ 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 @@ -548,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 @@ -622,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 @@ -717,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 @@ -879,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 () @@ -967,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 () @@ -1201,6 +1195,12 @@ deleteMemberConnection m@GroupMember {activeConn} = do -- 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 @@ -1267,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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9d3175f1d9..1dd95dbc86 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -229,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/Store.hs b/src/Simplex/Chat/Store.hs index 16f1257538..757b639407 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2453,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/View.hs b/src/Simplex/Chat/View.hs index 01e697dc6c..eb4458570a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -474,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"] From 928dd27043514af4c37ef9ead8d6001ad2632210 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 21:21:16 +0400 Subject: [PATCH 14/17] prepare v1.2.0 (#302) --- cabal.project | 2 +- package.yaml | 2 +- sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Controller.hs | 2 +- stack.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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/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/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 f773fc489f..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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1dd95dbc86..7b5d11794f 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 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 From 44d8b549c48b200aececd35048cc4935ffb38885 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 17:51:50 +0000 Subject: [PATCH 15/17] return version number to mobile (#303) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 809e3d9cde..715e460c87 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -375,7 +375,7 @@ processChatCommand = \case 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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7b5d11794f..7eec8ee084 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -153,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 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index eb4458570a..08ad47543c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -61,7 +61,7 @@ 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!"] From fdf312d9e14d34ebe0541c1c4e2bc9fcb4bf0b65 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 21:52:01 +0400 Subject: [PATCH 16/17] ios: add contactNotReady error type (#304) --- apps/ios/Shared/Model/SimpleXAPI.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index db11cdd4b8..051f6df905 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -575,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) From c580c34a35d52330f72f0391b580502b6effc804 Mon Sep 17 00:00:00 2001 From: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com> Date: Mon, 14 Feb 2022 21:55:39 +0400 Subject: [PATCH 17/17] 1.2.0