From f42a6751b1c17b8392ebd5ce0918385c2903da25 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:18:29 +0000 Subject: [PATCH] core: allow to manually accept member contact requests (#6129) * core: allow to manually accept member contact requests * response * comment * comment * add fields * fix * field in request * compiles * fix tests * test * plans * fix mobile tests * fix doc tests * renames * group name in event * fix renames * tests * plans * rename selector * ios wip * fix * ios wip * move * fix backend bug, ui * reject dialogue * update plans * kotlin * delete swipe * should accept text * rename * postgres migration * ios: pass chat as binding * rename module * fix queries * schema * update plans, api docs --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/AppAPITypes.swift | 10 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 31 ++++ apps/ios/Shared/Views/Chat/ChatView.swift | 6 +- .../Chat/ComposeMessage/ComposeView.swift | 2 + .../ContextMemberContactActionsView.swift | 110 ++++++++++++ .../Views/ChatList/ChatPreviewView.swift | 2 +- .../Views/Contacts/ContactListNavLink.swift | 24 ++- .../Views/UserSettings/PrivacySettings.swift | 44 ++++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 39 +++- .../chat/simplex/common/model/ChatModel.kt | 23 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 25 +++ .../simplex/common/views/chat/ChatView.kt | 2 + ...ContextGroupDirectInvitationActionsView.kt | 170 ++++++++++++++++++ .../simplex/common/views/chat/ComposeView.kt | 10 ++ .../common/views/chatlist/ChatPreviewView.kt | 2 +- .../views/contacts/ContactPreviewView.kt | 28 +-- .../views/usersettings/PrivacySettings.kt | 43 +++++ .../commonMain/resources/MR/base/strings.xml | 8 +- bots/api/TYPES.md | 19 ++ bots/src/API/Docs/Commands.hs | 6 +- bots/src/API/Docs/Responses.hs | 1 + bots/src/API/Docs/Types.hs | 2 + simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 5 + src/Simplex/Chat/Library/Commands.hs | 52 ++++++ src/Simplex/Chat/Library/Internal.hs | 1 - src/Simplex/Chat/Library/Subscriber.hs | 56 ++++-- src/Simplex/Chat/Messages/CIContent.hs | 2 + src/Simplex/Chat/Messages/CIContent/Events.hs | 1 + src/Simplex/Chat/Store/Connections.hs | 15 +- src/Simplex/Chat/Store/ContactRequest.hs | 3 +- src/Simplex/Chat/Store/Direct.hs | 7 +- src/Simplex/Chat/Store/Groups.hs | 118 ++++++------ src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20250729_member_contact_requests.hs | 41 +++++ .../Store/Postgres/Migrations/chat_schema.sql | 37 +++- src/Simplex/Chat/Store/Profiles.hs | 12 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20250729_member_contact_requests.hs | 38 ++++ .../SQLite/Migrations/chat_query_plans.txt | 68 ++++--- .../Store/SQLite/Migrations/chat_schema.sql | 17 +- src/Simplex/Chat/Store/Shared.hs | 26 ++- src/Simplex/Chat/Types.hs | 25 ++- src/Simplex/Chat/View.hs | 13 +- tests/ChatTests/Groups.hs | 70 ++++++++ tests/ChatTests/Profiles.hs | 6 + tests/JSONFixtures.hs | 6 +- 48 files changed, 1088 insertions(+), 152 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20250729_member_contact_requests.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250729_member_contact_requests.hs diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 91fa6c4280..2055a0ab99 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -18,6 +18,7 @@ enum ChatCommand: ChatCmdProtocol { case setAllContactReceipts(enable: Bool) case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) + case apiSetUserAutoAcceptMemberContacts(userId: Int64, enable: Bool) case apiHideUser(userId: Int64, viewPwd: String) case apiUnhideUser(userId: Int64, viewPwd: String) case apiMuteUser(userId: Int64) @@ -83,6 +84,7 @@ enum ChatCommand: ChatCmdProtocol { case apiAddGroupShortLink(groupId: Int64) case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) + case apiAcceptMemberContact(contactId: Int64) case apiTestProtoServer(userId: Int64, server: String) case apiGetServerOperators case apiSetServerOperators(operators: [ServerOperator]) @@ -198,6 +200,8 @@ enum ChatCommand: ChatCmdProtocol { case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings): let umrs = userMsgReceiptSettings return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" + case let .apiSetUserAutoAcceptMemberContacts(userId, enable): + return "/_set accept member contacts \(userId) \(onOff(enable))" case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" case let .apiMuteUser(userId): return "/_mute user \(userId)" @@ -273,6 +277,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAddGroupShortLink(groupId): return "/_short link #\(groupId)" case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" + case let .apiAcceptMemberContact(contactId): return "/_accept member contact @\(contactId)" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" case .apiGetServerOperators: return "/_operators" case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" @@ -391,6 +396,7 @@ enum ChatCommand: ChatCmdProtocol { case .setAllContactReceipts: return "setAllContactReceipts" case .apiSetUserContactReceipts: return "apiSetUserContactReceipts" case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts" + case .apiSetUserAutoAcceptMemberContacts: return "apiSetUserAutoAcceptMemberContacts" case .apiHideUser: return "apiHideUser" case .apiUnhideUser: return "apiUnhideUser" case .apiMuteUser: return "apiMuteUser" @@ -457,6 +463,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiAddGroupShortLink: return "apiAddGroupShortLink" case .apiCreateMemberContact: return "apiCreateMemberContact" case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" + case .apiAcceptMemberContact: return "apiAcceptMemberContact" case .apiTestProtoServer: return "apiTestProtoServer" case .apiGetServerOperators: return "apiGetServerOperators" case .apiSetServerOperators: return "apiSetServerOperators" @@ -912,6 +919,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) + case memberContactAccepted(user: UserRef, contact: Contact) // receiving file responses case rcvFileAccepted(user: UserRef, chatItem: AChatItem) case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) @@ -960,6 +968,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .groupLinkDeleted: "groupLinkDeleted" case .newMemberContact: "newMemberContact" case .newMemberContactSentInv: "newMemberContactSentInv" + case .memberContactAccepted: "memberContactAccepted" case .rcvFileAccepted: "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" case .standaloneFileInfo: "standaloneFileInfo" @@ -1004,6 +1013,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") + case let .memberContactAccepted(u, contact): return withUser(u, "contact: \(contact)") case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index be1bea4469..ae9f21e34b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -289,6 +289,10 @@ func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgRec try await sendCommandOkResp(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings)) } +func apiSetUserAutoAcceptMemberContacts(_ userId: Int64, enable: Bool) async throws { + try await sendCommandOkResp(.apiSetUserAutoAcceptMemberContacts(userId: userId, enable: enable)) +} + func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User { try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd)) } @@ -1916,6 +1920,33 @@ func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async throw r.unexpected } +func apiAcceptMemberContact(contactId: Int64) async -> Contact? { + let r: APIResult? = await chatApiSendCmdWithRetry(.apiAcceptMemberContact(contactId: contactId)) + if case let .result(.memberContactAccepted(_, contact)) = r { return contact } + if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) } + return nil +} + +func acceptMemberContact(contactId: Int64, inProgress: Binding? = nil) async { + await MainActor.run { inProgress?.wrappedValue = true } + if let contact = await apiAcceptMemberContact(contactId: contactId) { + await MainActor.run { + ChatModel.shared.updateContact(contact) + NetworkModel.shared.setContactNetworkStatus(contact, .connected) + inProgress?.wrappedValue = false + } + if contact.sndReady { + DispatchQueue.main.async { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(contact.id) + } + } + } + } else { + await MainActor.run { inProgress?.wrappedValue = false } + } +} + func apiGetVersion() throws -> CoreVersionInfo { let r: ChatResponse2 = try chatSendCmdSync(.showVersion) if case let .versionInfo(info, _, _) = r { return info } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4a4efb9adc..3328f6c231 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -733,7 +733,7 @@ struct ChatView: View { return Group { if case .chatBanner = ci.content { VStack { - ChatBannerView(chat: chat) + ChatBannerView(chat: $chat) .padding(.bottom, 90) .padding(.top, 8) @@ -822,7 +822,7 @@ struct ChatView: View { struct ChatBannerView: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness - @ObservedObject var chat: Chat + @Binding @ObservedObject var chat: Chat var body: some View { let v = VStack(spacing: 8) { @@ -957,6 +957,8 @@ struct ChatView: View { if !contact.sndReady && contact.active && !contact.sendMsgToConnect && !contact.nextAcceptContactRequest { contact.preparedContact?.uiConnLinkType == .con ? "contact should accept…" + : contact.contactGroupMemberId != nil + ? "contact should accept…" : "connecting…" } else { nil diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 53ce9f9bfc..876761a588 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -453,6 +453,8 @@ struct ComposeView: View { } } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { ContextContactRequestActionsView(contactRequestId: crId) + } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv { + ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) } else { HStack (alignment: .center) { attachmentButton() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift new file mode 100644 index 0000000000..9e90575af4 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift @@ -0,0 +1,110 @@ +// +// ContextMemberContactActionsView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 31.07.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ContextMemberContactActionsView: View { + @EnvironmentObject var theme: AppTheme + var contact: Contact + var groupDirectInv: GroupDirectInvitation + @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @State private var inProgress = false + @State private var progressByTimeout = false + + var body: some View { + VStack { + if groupDirectInv.memberRemoved { + Label("Member is deleted - can't accept request", systemImage: "info.circle") + .foregroundColor(theme.colors.secondary) + .font(.subheadline) + .padding(.horizontal) + .frame(maxWidth: .infinity, minHeight: 60) + } else { + HStack(spacing: 0) { + Button(role: .destructive, action: showRejectRequestAlert) { + Label("Reject", systemImage: "multiply") + } + .frame(maxWidth: .infinity, minHeight: 60) + + Button { + acceptRequest() + } label: { + Label("Accept", systemImage: "checkmark") + } + .frame(maxWidth: .infinity, minHeight: 60) + } + } + } + .disabled(inProgress || groupDirectInv.memberRemoved) + .frame(maxWidth: .infinity) + .background(ToolbarMaterial.material(toolbarMaterial)) + .opacity(progressByTimeout ? 0.4 : 1) + .overlay { + if progressByTimeout { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onChange(of: inProgress) { inPrgrs in + if inPrgrs { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } + } + } + + private func showRejectRequestAlert() { + showAlert( + NSLocalizedString("Reject contact request", comment: "alert title"), + message: NSLocalizedString("The sender will NOT be notified", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Reject", comment: "alert action"), style: .destructive) { _ in + deleteContact() + }, + cancelAlertAction + ]} + ) + } + + func deleteContact() { + Task { + do { + let _ct = try await apiDeleteContact(id: contact.contactId, chatDeleteMode: .full(notify: false)) + await MainActor.run { + ChatModel.shared.removeChat(contact.id) + ChatModel.shared.chatId = nil + } + } catch let error { + logger.error("apiDeleteContact: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error deleting chat!", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private func acceptRequest() { + Task { + await acceptMemberContact(contactId: contact.contactId, inProgress: $inProgress) + } + } +} + +#Preview { + ContextMemberContactActionsView( + contact: Contact.sampleData, + groupDirectInv: GroupDirectInvitation.sampleData + ) +} diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 01c62ca34f..1e2fda365f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -167,7 +167,7 @@ struct ChatPreviewView: View { let color = deleting ? theme.colors.secondary - : contact.nextAcceptContactRequest || contact.sendMsgToConnect + : (contact.nextAcceptContactRequest && !(contact.groupDirectInv?.memberRemoved ?? false)) || contact.sendMsgToConnect ? theme.colors.primary : !contact.sndReady ? theme.colors.secondary diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index 253dca67c5..1c5a3bfaac 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -81,7 +81,7 @@ struct ContactListNavLink: View { ItemsModel.shared.loadOpenChat(contact.id) } } label: { - contactRequestPreview() + contactRequestPreview(color: contact.groupDirectInv?.memberRemoved == true ? theme.colors.secondary : theme.colors.primary) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if let contactRequestId = contact.contactRequestId { @@ -103,6 +103,20 @@ struct ContactListNavLink: View { Label("Reject", systemImage: "multiply") } .tint(.red) + } else { + Button { + deleteContactDialog( + chat, + contact, + dismissToChatList: false, + showAlert: { alert = $0 }, + showActionSheet: { actionSheet = $0 }, + showSheetContent: { sheet = $0 } + ) + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) } } } @@ -254,7 +268,7 @@ struct ContactListNavLink: View { Button { showContactRequestDialog = true } label: { - contactRequestPreview() + contactRequestPreview(color: theme.colors.primary) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { @@ -285,12 +299,12 @@ struct ContactListNavLink: View { } } - func contactRequestPreview() -> some View { + func contactRequestPreview(color: Color) -> some View { HStack{ ProfileImage(imageStr: chat.chatInfo.image, size: 30) Text(chat.chatInfo.chatViewName) - .foregroundColor(.accentColor) + .foregroundColor(color) .lineLimit(1) Spacer() @@ -299,7 +313,7 @@ struct ContactListNavLink: View { .resizable() .scaledToFill() .frame(width: 14, height: 14) - .foregroundColor(.accentColor) + .foregroundColor(color) } } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 5a9ffe5c8b..c4aff6180c 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -32,6 +32,8 @@ struct PrivacySettings: View { @State private var groupReceiptsReset = false @State private var groupReceiptsOverrides = 0 @State private var groupReceiptsDialogue = false + @State private var autoAcceptMemberContacts = false + @State private var autoAcceptMemberContactsReset = false @State private var alert: PrivacySettingsViewAlert? enum PrivacySettingsViewAlert: Identifiable { @@ -149,6 +151,18 @@ struct PrivacySettings: View { } } + Section { + settingsRow("checkmark", color: theme.colors.secondary) { + Toggle("Auto-accept", isOn: $autoAcceptMemberContacts) + } + } header: { + Text("Contact requests from groups") + .foregroundColor(theme.colors.secondary) + } footer: { + Text("This setting is for your current profile **\(m.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + } + Section { settingsRow("person", color: theme.colors.secondary) { Toggle("Contacts", isOn: $contactReceipts) @@ -207,6 +221,13 @@ struct PrivacySettings: View { setOrAskSendReceiptsGroups(groupReceipts) } } + .onChange(of: autoAcceptMemberContacts) { _ in + if autoAcceptMemberContactsReset { + autoAcceptMemberContactsReset = false + } else { + setAutoAcceptGrpDirectInvs(autoAcceptMemberContacts) + } + } .onAppear { if let u = m.currentUser { if contactReceipts != u.sendRcptsContacts { @@ -217,6 +238,10 @@ struct PrivacySettings: View { groupReceiptsReset = true groupReceipts = u.sendRcptsSmallGroups } + if autoAcceptMemberContacts != u.autoAcceptMemberContacts { + autoAcceptMemberContactsReset = true + autoAcceptMemberContacts = u.autoAcceptMemberContacts + } } } .alert(item: $alert) { alert in @@ -333,6 +358,23 @@ struct PrivacySettings: View { } } + private func setAutoAcceptGrpDirectInvs(_ enable: Bool) { + Task { + do { + if let currentUser = m.currentUser { + try await apiSetUserAutoAcceptMemberContacts(currentUser.userId, enable: enable) + await MainActor.run { + var updatedUser = currentUser + updatedUser.autoAcceptMemberContacts = enable + m.updateUser(updatedUser) + } + } + } catch let error { + alert = .error(title: "Error setting auto-accept for direct invitations from groups!", error: "Error: \(responseError(error))") + } + } + } + private func simplexLockRow(_ value: LocalizedStringKey) -> some View { HStack { Text("SimpleX Lock") @@ -445,7 +487,7 @@ struct SimplexLockView: View { Toggle("Allow sharing", isOn: $allowShareExtension) } } - + if performLA && laMode == .passcode { Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) { Toggle(isOn: $selfDestruct) { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5fb83b24b4..ced96d3909 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -189,6 +189,7 @@ 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; + 64FC8F9D2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */; }; 8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */ = {isa = PBXBuildFile; fileRef = 8C01E9C02C8EFC33008A4B0A /* objc.m */; }; 8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */ = {isa = PBXBuildFile; fileRef = 8C01E9BF2C8EFBB6008A4B0A /* objc.h */; }; 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; @@ -555,6 +556,7 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; + 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMemberContactActionsView.swift; sourceTree = ""; }; 8C01E9BF2C8EFBB6008A4B0A /* objc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = objc.h; sourceTree = ""; }; 8C01E9C02C8EFC33008A4B0A /* objc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = objc.m; sourceTree = ""; }; 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; @@ -1090,6 +1092,7 @@ 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */, 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */, 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */, + 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -1521,6 +1524,7 @@ 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, + 64FC8F9D2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift in Sources */, 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */, 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9b7f4ac5ee..faf7963192 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -39,6 +39,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var showNtfs: Bool public var sendRcptsContacts: Bool public var sendRcptsSmallGroups: Bool + public var autoAcceptMemberContacts: Bool public var viewPwdHash: UserPwdHash? public var uiThemes: ThemeModeOverrides? @@ -65,7 +66,8 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { activeOrder: 0, showNtfs: true, sendRcptsContacts: true, - sendRcptsSmallGroups: false + sendRcptsSmallGroups: false, + autoAcceptMemberContacts: false ) } @@ -1759,8 +1761,9 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { var chatTs: Date? public var preparedContact: PreparedContact? public var contactRequestId: Int64? - var contactGroupMemberId: Int64? + public var contactGroupMemberId: Int64? var contactGrpInvSent: Bool + public var groupDirectInv: GroupDirectInvitation? public var chatTags: [Int64] public var chatItemTTL: Int64? public var uiThemes: ThemeModeOverrides? @@ -1774,7 +1777,11 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } public var nextConnectPrepared: Bool { active && preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) } public var profileChangeProhibited: Bool { activeConn != nil } - public var nextAcceptContactRequest: Bool { active && contactRequestId != nil && (activeConn == nil || activeConn?.connStatus == .new) } + public var nextAcceptContactRequest: Bool { + active && + (contactRequestId != nil || groupDirectInv != nil) && + (activeConn == nil || activeConn?.connStatus == .new || activeConn?.connStatus == .prepared) + } public var sendMsgToConnect: Bool { nextSendGrpInv || nextConnectPrepared } public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } @@ -1843,6 +1850,26 @@ public struct PreparedContact: Decodable, Hashable { public var uiConnLinkType: ConnectionMode } +public struct GroupDirectInvitation: Decodable, Hashable { + public var groupDirectInvLink: String + public var fromGroupId_: Int64? + public var fromGroupMemberId_: Int64? + public var fromGroupMemberConnId_: Int64? + public var groupDirectInvStartedConnection: Bool + + public var memberRemoved: Bool { + fromGroupId_ == nil || fromGroupMemberId_ == nil || fromGroupMemberConnId_ == nil + } + + public static let sampleData = GroupDirectInvitation( + groupDirectInvLink: "simplex_link", + fromGroupId_: 1, + fromGroupMemberId_: 1, + fromGroupMemberConnId_: 1, + groupDirectInvStartedConnection: false + ) +} + public enum ConnectionMode: String, Decodable, Hashable { case inv case con @@ -2895,6 +2922,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { switch rcvDirectEvent { case .contactDeleted: return false case .profileUpdated: return false + case .groupInvLinkReceived: return true } case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): switch rcvGroupEvent { @@ -4702,11 +4730,14 @@ public struct E2EEInfo: Decodable, Hashable { public enum RcvDirectEvent: Decodable, Hashable { case contactDeleted case profileUpdated(fromProfile: Profile, toProfile: Profile) + case groupInvLinkReceived(groupProfile: Profile) var text: String { switch self { case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item") case let .profileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) + case let .groupInvLinkReceived(groupProfile): + return String.localizedStringWithFormat(NSLocalizedString("requested connection from group %@", comment: "rcv direct event chat item"), groupProfile.displayName) } } @@ -4771,7 +4802,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item") case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item") case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item") - case .memberCreatedContact: return NSLocalizedString("connected directly", comment: "rcv group event chat item") + case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) case .newMemberPendingReview: return NSLocalizedString("New member wants to join the group.", comment: "rcv group event chat item") } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a7ede11160..8dce803f27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1228,6 +1228,7 @@ data class User( override val showNtfs: Boolean, val sendRcptsContacts: Boolean, val sendRcptsSmallGroups: Boolean, + val autoAcceptMemberContacts: Boolean, val viewPwdHash: UserPwdHash?, val uiThemes: ThemeModeOverrides? = null, ): NamedChat, UserLike { @@ -1257,6 +1258,7 @@ data class User( showNtfs = true, sendRcptsContacts = true, sendRcptsSmallGroups = false, + autoAcceptMemberContacts = false, viewPwdHash = null, uiThemes = null, ) @@ -1730,6 +1732,7 @@ data class Contact( val contactRequestId: Long?, val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, + val groupDirectInv: GroupDirectInvitation? = null, val chatTags: List, val chatItemTTL: Long?, override val chatDeleted: Boolean, @@ -1745,7 +1748,10 @@ data class Contact( val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent override val nextConnectPrepared get() = active && preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) override val profileChangeProhibited get() = activeConn != null - val nextAcceptContactRequest get() = active && contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New) + val nextAcceptContactRequest get() = + active && + (contactRequestId != null || groupDirectInv != null) && + (activeConn == null || activeConn.connStatus == ConnStatus.New || activeConn.connStatus == ConnStatus.Prepared) val sendMsgToConnect get() = nextSendGrpInv || nextConnectPrepared override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { @@ -1836,6 +1842,18 @@ data class PreparedContact ( val uiConnLinkType: ConnectionMode ) +@Serializable +data class GroupDirectInvitation ( + val groupDirectInvLink: String, + val fromGroupId_: Long?, + val fromGroupMemberId_: Long?, + val fromGroupMemberConnId_: Long?, + val groupDirectInvStartedConnection: Boolean +) { + val memberRemoved: Boolean + get() = fromGroupId_ == null || fromGroupMemberId_ == null || fromGroupMemberConnId_ == null +} + @Serializable enum class ConnectionMode { @SerialName("inv") Inv, @@ -2843,6 +2861,7 @@ data class ChatItem ( is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) { is RcvDirectEvent.ContactDeleted -> false is RcvDirectEvent.ProfileUpdated -> false + is RcvDirectEvent.GroupInvLinkReceived -> true } is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false @@ -4511,10 +4530,12 @@ sealed class MsgErrorType() { sealed class RcvDirectEvent() { @Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent() @Serializable @SerialName("profileUpdated") class ProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvDirectEvent() + @Serializable @SerialName("groupInvLinkReceived") class GroupInvLinkReceived(val groupProfile: GroupProfile): RcvDirectEvent() val text: String get() = when (this) { is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted) is ProfileUpdated -> profileUpdatedText(fromProfile, toProfile) + is GroupInvLinkReceived -> generalGetString(MR.strings.rcv_direct_event_group_inv_link_received).format(groupProfile.displayName) } private fun profileUpdatedText(from: Profile, to: Profile): String = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 839e90fad0..a72aa8694c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -857,6 +857,12 @@ object ChatController { throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}") } + suspend fun apiSetUserAutoAcceptMemberContacts(u: User, enable: Boolean) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserAutoAcceptMemberContacts(u.userId, enable)) + if (r.result is CR.CmdOk) return + throw Exception("failed to set auto-accept for direct invitations from groups ${r.responseType} ${r.details}") + } + suspend fun apiHideUser(u: User, viewPwd: String): User = setUserPrivacy(u.remoteHostId, CC.ApiHideUser(u.userId, viewPwd)) @@ -2207,6 +2213,16 @@ object ChatController { return null } + suspend fun apiAcceptMemberContact(rh: Long?, contactId: Long): Contact? { + val r = sendCmdWithRetry(rh, CC.APIAcceptMemberContact(contactId)) + if (r is API.Result && r.res is CR.MemberContactAccepted) return r.res.contact + if (r != null) { + Log.e(TAG, "apiAcceptMemberContact bad response: ${r.responseType} ${r.details}") + apiConnectResponseAlert(r) + } + return null + } + suspend fun allowFeatureToContact(rh: Long?, contact: Contact, feature: ChatFeature, param: Int? = null) { val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param) val toContact = apiSetContactPrefs(rh, contact.contactId, prefs) @@ -3510,6 +3526,7 @@ sealed class CC { class SetAllContactReceipts(val enable: Boolean): CC() class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() class ApiSetUserGroupReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() + class ApiSetUserAutoAcceptMemberContacts(val userId: Long, val enable: Boolean): CC() class ApiHideUser(val userId: Long, val viewPwd: String): CC() class ApiUnhideUser(val userId: Long, val viewPwd: String): CC() class ApiMuteUser(val userId: Long): CC() @@ -3567,6 +3584,7 @@ sealed class CC { class ApiAddGroupShortLink(val groupId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() + class APIAcceptMemberContact(val contactId: Long): CC() class APITestProtoServer(val userId: Long, val server: String): CC() class ApiGetServerOperators(): CC() class ApiSetServerOperators(val operators: List): CC() @@ -3687,6 +3705,7 @@ sealed class CC { val mrs = userMsgReceiptSettings "/_set receipts groups $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" } + is ApiSetUserAutoAcceptMemberContacts -> "/_set accept member contacts $userId ${onOff(enable)}" is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}" is ApiMuteUser -> "/_mute user $userId" @@ -3762,6 +3781,7 @@ sealed class CC { is ApiAddGroupShortLink -> "/_short link #$groupId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" + is APIAcceptMemberContact -> "/_accept member contact @$contactId" is APITestProtoServer -> "/_server test $userId $server" is ApiGetServerOperators -> "/_operators" is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" @@ -3879,6 +3899,7 @@ sealed class CC { is SetAllContactReceipts -> "setAllContactReceipts" is ApiSetUserContactReceipts -> "apiSetUserContactReceipts" is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts" + is ApiSetUserAutoAcceptMemberContacts -> "apiSetUserAutoAcceptMemberContacts" is ApiHideUser -> "apiHideUser" is ApiUnhideUser -> "apiUnhideUser" is ApiMuteUser -> "apiMuteUser" @@ -3935,6 +3956,7 @@ sealed class CC { is ApiAddGroupShortLink -> "apiAddGroupShortLink" is APICreateMemberContact -> "apiCreateMemberContact" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" + is APIAcceptMemberContact -> "apiAcceptMemberContact" is APITestProtoServer -> "testProtoServer" is ApiGetServerOperators -> "apiGetServerOperators" is ApiSetServerOperators -> "apiSetServerOperators" @@ -6127,6 +6149,7 @@ sealed class CR { @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("newMemberContact") class NewMemberContact(val user: UserRef, val contact: Contact, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("newMemberContactSentInv") class NewMemberContactSentInv(val user: UserRef, val contact: Contact, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberContactAccepted") class MemberContactAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("newMemberContactReceivedInv") class NewMemberContactReceivedInv(val user: UserRef, val contact: Contact, val groupInfo: GroupInfo, val member: GroupMember): CR() // receiving file events @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: UserRef, val chatItem: AChatItem): CR() @@ -6310,6 +6333,7 @@ sealed class CR { is GroupLinkDeleted -> "groupLinkDeleted" is NewMemberContact -> "newMemberContact" is NewMemberContactSentInv -> "newMemberContactSentInv" + is MemberContactAccepted -> "memberContactAccepted" is NewMemberContactReceivedInv -> "newMemberContactReceivedInv" is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled" is StandaloneFileInfo -> "standaloneFileInfo" @@ -6486,6 +6510,7 @@ sealed class CR { is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) is NewMemberContact -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is NewMemberContactSentInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") + is MemberContactAccepted -> withUser(user, "contact: $contact") is NewMemberContactReceivedInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is RcvFileAcceptedSndCancelled -> withUser(user, noDetails()) is StandaloneFileInfo -> json.encodeToString(fileMeta) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 074609c8f7..7f38874f92 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -770,6 +770,8 @@ private fun connectingText(chatInfo: ChatInfo): String? { ) { if (chatInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) { generalGetString(MR.strings.contact_should_accept) + } else if (chatInfo.contact.contactGroupMemberId != null) { + generalGetString(MR.strings.contact_should_accept) } else { generalGetString(MR.strings.contact_connection_pending) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt new file mode 100644 index 0000000000..78a5407e00 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt @@ -0,0 +1,170 @@ +package chat.simplex.common.views.chat + +import TextIconSpaced +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* + +@Composable +fun ComposeContextMemberContactActionsView( + rhId: Long?, + contact: Contact, + groupDirectInv: GroupDirectInvitation +) { + val inProgress = rememberSaveable { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + + KeyChangeEffect(chatModel.chatId.value) { + if (inProgress.value) { + inProgress.value = false + progressByTimeout = false + } + } + + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } + + Box( + Modifier.height(60.dp), + contentAlignment = Alignment.Center + ) { + Column( + Modifier + .background(MaterialTheme.colors.surface) + .alpha(if (progressByTimeout) 0.6f else 1f) + ) { + Divider() + + if (groupDirectInv.memberRemoved) { + Row( + Modifier + .fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + Icon(painterResource(MR.images.ic_info), contentDescription = null, tint = MaterialTheme.colors.secondary) + Text(generalGetString(MR.strings.member_is_deleted_cant_accept_request), color = MaterialTheme.colors.secondary) + } + } else { + Row( + Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + var rejectButtonModifier = Modifier.fillMaxWidth().fillMaxHeight().weight(1F) + rejectButtonModifier = + if (inProgress.value) rejectButtonModifier + else rejectButtonModifier.clickable { showRejectMemberContactRequestAlert(rhId, contact) } + Row( + rejectButtonModifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = null, + tint = if (inProgress.value) MaterialTheme.colors.secondary else Color.Red, + ) + TextIconSpaced(false) + Text( + stringResource(MR.strings.reject_contact_button), + color = if (inProgress.value) MaterialTheme.colors.secondary else Color.Red + ) + } + var acceptButtonModifier = Modifier.fillMaxWidth().fillMaxHeight().weight(1F) + acceptButtonModifier = + if (inProgress.value) acceptButtonModifier + else acceptButtonModifier.clickable { acceptMemberContact(rhId, contact.contactId, inProgress = inProgress) } + Row( + acceptButtonModifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(MR.images.ic_check), + contentDescription = null, + tint = if (inProgress.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + ) + TextIconSpaced(false) + Text( + stringResource(MR.strings.accept_contact_button), + color = if (inProgress.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + } + } + + if (progressByTimeout) { + ComposeProgressIndicator() + } + } +} + +fun showRejectMemberContactRequestAlert(rhId: Long?, contact: Contact) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.reject_contact_request), + text = generalGetString(MR.strings.the_sender_will_not_be_notified), + confirmText = generalGetString(MR.strings.reject_contact_button), + onConfirm = { + AlertManager.shared.hideAlert() + deleteMemberContact(rhId, contact) + }, + destructive = true, + hostDevice = hostDevice(rhId), + ) +} + +private fun deleteMemberContact(rhId: Long?, contact: Contact) { + withBGApi { + chatModel.controller.apiDeleteContact(rhId, contact.contactId, chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false)) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(rhId, contact.id) + chatModel.chatId.value = null + } + } +} + +fun acceptMemberContact( + rhId: Long?, + contactId: Long, + close: ((chat: Chat) -> Unit)? = null, // currently unused, can pass function to open chat if reused in other views (e.g. see onRequestAccepted) + inProgress: MutableState? = null +) { + withBGApi { + inProgress?.value = true + val contact = chatModel.controller.apiAcceptMemberContact(rhId, contactId) + if (contact != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContact(rhId, contact) + inProgress?.value = false + } + chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) + val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) + close?.invoke(chat) + } else { + inProgress?.value = false + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index f1b370a796..379814c0ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1467,6 +1467,16 @@ fun ComposeView( rhId = rhId, contactRequestId = chat.chatInfo.contact.contactRequestId ) + } else if ( + chat.chatInfo is ChatInfo.Direct + && chat.chatInfo.contact.nextAcceptContactRequest + && chat.chatInfo.contact.groupDirectInv != null + ) { + ComposeContextMemberContactActionsView( + rhId = rhId, + contact = chat.chatInfo.contact, + groupDirectInv = chat.chatInfo.contact.groupDirectInv + ) } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { AttachmentButton() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 385a24cd50..e6c74b7558 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -144,7 +144,7 @@ fun ChatPreviewView( } val color = if (deleting) MaterialTheme.colors.secondary - else if (cInfo.contact.nextAcceptContactRequest || cInfo.contact.sendMsgToConnect) { + else if ((cInfo.contact.nextAcceptContactRequest && cInfo.contact.groupDirectInv?.memberRemoved != true) || cInfo.contact.sendMsgToConnect) { MaterialTheme.colors.primary } else if (!cInfo.contact.sndReady) { MaterialTheme.colors.secondary diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index b86f6d7a3e..636887275c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -31,19 +31,23 @@ fun ContactPreviewView( Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } + val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } + + val textColor = when { + deleting -> MaterialTheme.colors.secondary + contactType == ContactType.CARD -> MaterialTheme.colors.primary + contactType == ContactType.CONTACT_WITH_REQUEST -> + if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.groupDirectInv?.memberRemoved == true) + MaterialTheme.colors.secondary + else + MaterialTheme.colors.primary + contactType == ContactType.REQUEST -> MaterialTheme.colors.primary + contactType == ContactType.RECENT -> if (chat.chatInfo.nextConnect) MaterialTheme.colors.primary else Color.Unspecified + else -> Color.Unspecified + } + @Composable fun chatPreviewTitle() { - val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } - - val textColor = when { - deleting -> MaterialTheme.colors.secondary - contactType == ContactType.CARD -> MaterialTheme.colors.primary - contactType == ContactType.CONTACT_WITH_REQUEST -> MaterialTheme.colors.primary - contactType == ContactType.REQUEST -> MaterialTheme.colors.primary - contactType == ContactType.RECENT -> if (chat.chatInfo.nextConnect) MaterialTheme.colors.primary else Color.Unspecified - else -> Color.Unspecified - } - when (cInfo) { is ChatInfo.Direct -> Row(verticalAlignment = Alignment.CenterVertically) { @@ -90,7 +94,7 @@ fun ContactPreviewView( Icon( painterResource(MR.images.ic_check), contentDescription = null, - tint = MaterialTheme.colors.primary, + tint = textColor, modifier = Modifier .size(23.dp) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index ab4c41ca93..915119fa64 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -161,7 +161,22 @@ fun PrivacySettingsView( } } + fun setAutoAcceptGrpDirectInvs(enable: Boolean) { + withApi { + chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable) + chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable) + } + } + if (!chatModel.desktopNoUserNoRemote) { + SectionDividerSpaced(maxTopPadding = true) + ContacRequestsFromGroupsSection( + currentUser = currentUser, + setAutoAcceptGrpDirectInvs = { enable -> + setAutoAcceptGrpDirectInvs(enable) + } + ) + SectionDividerSpaced(maxTopPadding = true) DeliveryReceiptsSection( currentUser = currentUser, @@ -275,6 +290,34 @@ expect fun PrivacyDeviceSection( setPerformLA: (Boolean) -> Unit, ) +@Composable +private fun ContacRequestsFromGroupsSection( + currentUser: User, + setAutoAcceptGrpDirectInvs: (Boolean) -> Unit +) { + SectionView(stringResource(MR.strings.settings_section_title_contact_requests_from_groups)) { + SettingsActionItemWithContent(painterResource(MR.images.ic_check), stringResource(MR.strings.auto_accept_contact)) { + DefaultSwitch( + checked = currentUser.autoAcceptMemberContacts, + onCheckedChange = { enable -> + setAutoAcceptGrpDirectInvs(enable) + } + ) + } + } + SectionTextFooter( + remember(currentUser.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.this_setting_is_for_your_current_profile) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser.displayName) + } + append(".") + } + } + ) +} + @Composable private fun DeliveryReceiptsSection( currentUser: User, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 9e87b35294..e962c3a646 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -732,6 +732,9 @@ Reject contact request The sender will NOT be notified. + + Member is deleted - can\'t accept request + Clear chat? Clear private notes? @@ -1395,6 +1398,7 @@ An empty chat profile with the provided name is created, and the app opens as usual. If you enter this passcode when opening the app, all app data will be irreversibly removed! Set passcode + This setting is for your current profile These settings are for your current profile They can be overridden in contact and group settings. Contacts @@ -1438,6 +1442,7 @@ CHATS FILES SEND DELIVERY RECEIPTS TO + CONTACT REQUESTS FROM GROUPS Restart Shutdown Developer tools @@ -1646,6 +1651,7 @@ deleted contact + requested connection from group %1$s invited %1$s @@ -1662,7 +1668,7 @@ deleted group updated group profile invited via your group link - connected directly + requested connection New member wants to join the group. you changed role of %s to %s you changed role for yourself to %s diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index d3badbd598..c4e7ac6397 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -82,6 +82,7 @@ This file is generated automatically. - [FullPreferences](#fullpreferences) - [GroupChatScope](#groupchatscope) - [GroupChatScopeInfo](#groupchatscopeinfo) +- [GroupDirectInvitation](#groupdirectinvitation) - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) @@ -1595,6 +1596,7 @@ Error: - contactRequestId: int64? - contactGroupMemberId: int64? - contactGrpInvSent: bool +- groupDirectInv: [GroupDirectInvitation](#groupdirectinvitation)? - chatTags: [int64] - chatItemTTL: int64? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? @@ -2032,6 +2034,18 @@ MemberSupport: - groupMember_: [GroupMember](#groupmember)? +--- + +## GroupDirectInvitation + +**Record type**: +- groupDirectInvLink: string +- fromGroupId_: int64? +- fromGroupMemberId_: int64? +- fromGroupMemberConnId_: int64? +- groupDirectInvStartedConnection: bool + + --- ## GroupFeature @@ -2810,6 +2824,10 @@ ProfileUpdated: - fromProfile: [Profile](#profile) - toProfile: [Profile](#profile) +GroupInvLinkReceived: +- type: "groupInvLinkReceived" +- groupProfile: [GroupProfile](#groupprofile) + --- @@ -3601,6 +3619,7 @@ Handshake: - showNtfs: bool - sendRcptsContacts: bool - sendRcptsSmallGroups: bool +- autoAcceptMemberContacts: bool - userMemberProfileUpdatedAt: UTCTime? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 0f8b82f392..9630ea2070 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -245,6 +245,7 @@ cliCommands = "SendImage", "SendLiveMessage", "SendMemberContactMessage", + "AcceptMemberContact", "SendMessage", "SendMessageBroadcast", "SendMessageQuote", @@ -265,6 +266,7 @@ cliCommands = "SetUserContactReceipts", "SetUserFeature", "SetUserGroupReceipts", + "SetUserAutoAcceptMemberContacts", "SetUserTimedMessages", "ShowChatItem", "ShowChatItemInfo", @@ -320,6 +322,8 @@ undocumentedCommands = "APICreateChatItems", "APICreateChatTag", "APICreateMemberContact", + "APISendMemberContactInvitation", + "APIAcceptMemberContact", "APIDeleteChatTag", "APIDeleteMemberSupportChat", "APIDeleteReceivedReports", @@ -370,7 +374,6 @@ undocumentedCommands = "APISendCallExtraInfo", "APISendCallInvitation", "APISendCallOffer", - "APISendMemberContactInvitation", "APISetAppFilePaths", "APISetChatItemTTL", "APISetChatSettings", @@ -390,6 +393,7 @@ undocumentedCommands = "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", + "APISetUserAutoAcceptMemberContacts", "APISetUserServers", "APISetUserUIThemes", "APIStandaloneFileInfo", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 8a4e806b59..50e9c4c14b 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -171,6 +171,7 @@ undocumentedResponses = "CRNetworkStatuses", "CRNewMemberContact", "CRNewMemberContactSentInv", + "CRMemberContactAccepted", "CRNewPreparedChat", "CRNtfConns", "CRNtfToken", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 8309ee28ba..de1c664c24 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -302,6 +302,7 @@ chatTypesDocsData = (sti @PrefEnabled, STRecord, "", [], "", ""), (sti @Preferences, STRecord, "", [], "", ""), (sti @PreparedContact, STRecord, "", [], "", ""), + (sti @GroupDirectInvitation, STRecord, "", [], "", ""), (sti @PreparedGroup, STRecord, "", [], "", ""), (sti @Profile, STRecord, "", [], "", ""), (sti @ProxyClientError, STUnion, "Proxy", [], "", ""), @@ -492,6 +493,7 @@ deriving instance Generic PendingContactConnection deriving instance Generic PrefEnabled deriving instance Generic Preferences deriving instance Generic PreparedContact +deriving instance Generic GroupDirectInvitation deriving instance Generic PreparedGroup deriving instance Generic Profile deriving instance Generic ProxyClientError diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4f5e8877c4..b86c5ef3d2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -112,6 +112,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250704_groups_conn_link_prepared_connection Simplex.Chat.Store.Postgres.Migrations.M20250709_profile_short_descr Simplex.Chat.Store.Postgres.Migrations.M20250721_indexes + Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests else exposed-modules: Simplex.Chat.Archive @@ -249,6 +250,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250704_groups_conn_link_prepared_connection Simplex.Chat.Store.SQLite.Migrations.M20250709_profile_short_descr Simplex.Chat.Store.SQLite.Migrations.M20250721_indexes + Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 890f927e7b..b165ec135f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -265,6 +265,8 @@ data ChatCommand | SetUserContactReceipts UserMsgReceiptSettings | APISetUserGroupReceipts UserId UserMsgReceiptSettings | SetUserGroupReceipts UserMsgReceiptSettings + | APISetUserAutoAcceptMemberContacts UserId Bool + | SetUserAutoAcceptMemberContacts Bool | APIHideUser UserId UserPwd | APIUnhideUser UserId UserPwd | APIMuteUser UserId @@ -373,6 +375,7 @@ data ChatCommand | APIAddGroupShortLink GroupId | APICreateMemberContact GroupId GroupMemberId | APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent} + | APIAcceptMemberContact ContactId | GetUserProtoServers AProtocolType | SetUserProtoServers AProtocolType [AProtoServerWithAuth] | APITestProtoServer UserId AProtoServerWithAuth @@ -477,6 +480,7 @@ data ChatCommand | ForwardLocalMessage {toChatName :: ChatName, forwardedMsg :: Text} | SendMessage SendName Text | SendMemberContactMessage GroupName ContactName Text + | AcceptMemberContact ContactName | SendLiveMessage ChatName Text | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: Text, message :: Text} | SendMessageBroadcast MsgContent -- UserId (not used in UI) @@ -726,6 +730,7 @@ data ChatResponse | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberContactAccepted {user :: User, contact :: Contact} | CRCallInvitations {callInvitations :: [RcvCallInvitation]} | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 09e936c1d2..7c28844f48 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -379,6 +379,12 @@ processChatCommand vr nm = \case withFastStore' $ \db -> updateUserGroupReceipts db user' settings ok user SetUserGroupReceipts settings -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserGroupReceipts userId settings + APISetUserAutoAcceptMemberContacts userId' onOff -> withUser $ \user -> do + user' <- privateGetUser userId' + validateUserPassword user user' Nothing + withFastStore' $ \db -> updateUserAutoAcceptMemberContacts db user' onOff + ok user + SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserAutoAcceptMemberContacts userId onOff APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of @@ -2053,6 +2059,9 @@ processChatCommand vr nm = \case Just ctId -> do let sendRef = SRDirect ctId processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + AcceptMemberContact cName -> withUser $ \user -> do + contactId <- withFastStore $ \db -> getContactIdByName db user cName + processChatCommand vr nm $ APIAcceptMemberContact contactId SendLiveMessage chatName msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg withSendRef chatRef $ \sendRef -> do @@ -2607,6 +2616,45 @@ processChatCommand vr nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] pure $ CRNewMemberContactSentInv user ct' g m _ -> throwChatError CEGroupMemberNotActive + APIAcceptMemberContact contactId -> withUser $ \user -> do + (g, mConn, ct, groupDirectInv) <- withFastStore $ \db -> getMemberContactInvited db vr user contactId + when (groupDirectInvStartedConnection groupDirectInv) $ throwCmdError "connection already started" + connectMemberContact user g mConn ct groupDirectInv `catchChatError` \e -> do + -- get updated contact, in case connection was started + ct' <- withFastStore $ \db -> getContact db vr user contactId + toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') + throwError e + -- get updated contact (groupDirectInvStartedConnection) with connection + ct' <- withFastStore $ \db -> do + liftIO $ setMemberContactStartedConnection db ct + getContact db vr user contactId + pure $ CRMemberContactAccepted user ct' + where + connectMemberContact user gInfo mConn Contact {activeConn} GroupDirectInvitation {groupDirectInvLink = cReq} = + withInvitationLock "connect" (strEncode cReq) $ do + subMode <- chatReadVar subscriptionMode + case activeConn of + Nothing -> joinNewConn subMode + Just conn@Connection {connStatus} -> case connStatus of + ConnPrepared -> joinPreparedConn subMode conn + _ -> throwChatError $ CEException "connection already started (past prepared status)" + where + joinNewConn subMode = do + -- possible improvement: use agent connRequestPQSupport to determine pqSupport here; + -- for joinPreparedConn below - same + encodeConnInfoPQ; + -- same for auto-accept on xGrpDirectInv + acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + conn <- withStore $ \db -> do + connId <- liftIO $ createMemberContactConn db user acId Nothing gInfo mConn ConnPrepared contactId subMode + getConnectionById db vr user connId + joinPreparedConn subMode conn + joinPreparedConn subMode conn = do + -- [incognito] send membership incognito profile + let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True + dm <- encodeConnInfo $ XInfo p + (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode + let newStatus = if sqSecured then ConnSndReady else ConnJoined + void $ withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus CreateGroupLink gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APICreateGroupLink groupId mRole @@ -4336,6 +4384,8 @@ chatCommandP = "/set receipts contacts " *> (SetUserContactReceipts <$> receiptSettings), "/_set receipts groups " *> (APISetUserGroupReceipts <$> A.decimal <* A.space <*> receiptSettings), "/set receipts groups " *> (SetUserGroupReceipts <$> receiptSettings), + "/_set accept member contacts " *> (APISetUserAutoAcceptMemberContacts <$> A.decimal <* A.space <*> onOffP), + "/set accept member contacts " *> (SetUserAutoAcceptMemberContacts <$> onOffP), "/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP), "/_unhide user " *> (APIUnhideUser <$> A.decimal <* A.space <*> jsonP), "/_mute user " *> (APIMuteUser <$> A.decimal), @@ -4569,6 +4619,7 @@ chatCommandP = "/show link #" *> (ShowGroupLink <$> displayNameP), "/_create member contact #" *> (APICreateMemberContact <$> A.decimal <* A.space <*> A.decimal), "/_invite member contact @" *> (APISendMemberContactInvitation <$> A.decimal <*> optional (A.space *> msgContentP)), + "/_accept member contact @" *> (APIAcceptMemberContact <$> A.decimal), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <* char_ '@' <*> (Just <$> displayNameP) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), @@ -4592,6 +4643,7 @@ chatCommandP = ForwardLocalMessage <$> chatNameP <* " <- * " <*> msgTextP, SendMessage <$> sendNameP <* A.space <*> msgTextP, "@#" *> (SendMemberContactMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> msgTextP), + "/accept_member_contact @" *> (AcceptMemberContact <$> displayNameP), "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index d37a8a70d9..941ee04c9e 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -62,7 +62,6 @@ import Simplex.Chat.Operators import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store -import Simplex.Chat.Store.Connections import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f1f8cfb15c..013ae2fb26 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3065,7 +3065,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> CM () - xGrpDirectInv g m mConn connReq mContent_ msg brokerTs + xGrpDirectInv g@GroupInfo {groupId, groupProfile = gp} m mConn@Connection {connId = mConnId} connReq mContent_ msg brokerTs | not (groupFeatureMemberAllowed SGFDirectMessages m g) = messageError "x.grp.direct.inv: direct messages not allowed" | memberBlocked m = messageWarning "x.grp.direct.inv: member is blocked (ignoring)" | otherwise = do @@ -3087,17 +3087,49 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else createItems mCt m else joinExistingContact subMode mCt where - joinExistingContact subMode mCt = do - connIds <- joinConn subMode - mCt' <- withStore $ \db -> updateMemberContactInvited db user connIds g mConn mCt subMode - createItems mCt' m - securityCodeChanged mCt' - createNewContact subMode = do - connIds <- joinConn subMode - -- [incognito] reuse membership incognito profile - (mCt', m') <- withStore' $ \db -> createMemberContactInvited db user connIds g m mConn subMode - createInternalChatItem user (CDDirectSnd mCt') CIChatBanner (Just epochStart) - createItems mCt' m' + groupDirectInv = + GroupDirectInvitation { + groupDirectInvLink = connReq, + fromGroupId_ = Just groupId, + fromGroupMemberId_ = Just (groupMemberId' m), + fromGroupMemberConnId_ = Just mConnId, + groupDirectInvStartedConnection = isTrue $ autoAcceptMemberContacts user + } + joinExistingContact subMode mCt@Contact {contactId = mContactId} + | isTrue (autoAcceptMemberContacts user) = do + (cmdId, acId) <- joinConn subMode + mCt' <- withStore $ \db -> do + updateMemberContactInvited db user mCt groupDirectInv + void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode + getContact db vr user mContactId + securityCodeChanged mCt' + createItems mCt' m + | otherwise = do + mCt' <- withStore $ \db -> do + updateMemberContactInvited db user mCt groupDirectInv + getContact db vr user mContactId + securityCodeChanged mCt' + createInternalChatItem user (CDDirectRcv mCt') (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing + createItems mCt' m + createNewContact subMode + | isTrue (autoAcceptMemberContacts user) = do + (cmdId, acId) <- joinConn subMode + -- [incognito] reuse membership incognito profile + (mCt, m') <- withStore $ \db -> do + (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv + void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode + mCt <- getContact db vr user mContactId + pure (mCt, m') + createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) + createItems mCt m' + | otherwise = do + (mCt, m') <- withStore $ \db -> do + (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv + mCt <- getContact db vr user mContactId + pure (mCt, m') + createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) + createInternalChatItem user (CDDirectRcv mCt) (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing + createItems mCt m' joinConn subMode = do -- [incognito] send membership incognito profile let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 505f73d9cd..fd8f1cc41b 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -209,6 +209,7 @@ ciRequiresAttention content = case msgDirection @d of CIRcvDirectEvent rde -> case rde of RDEContactDeleted -> False RDEProfileUpdated {} -> False + RDEGroupInvLinkReceived _ -> True CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False @@ -328,6 +329,7 @@ rcvDirectEventToText :: RcvDirectEvent -> Text rcvDirectEventToText = \case RDEContactDeleted -> "contact deleted" RDEProfileUpdated {} -> "updated profile" + RDEGroupInvLinkReceived GroupProfile {displayName} -> "requested connection from group " <> displayName rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 539c1f524c..adacb06ee4 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -61,6 +61,7 @@ data SndConnEvent data RcvDirectEvent = RDEContactDeleted | RDEProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRContactUpdated + | RDEGroupInvLinkReceived {groupProfile :: GroupProfile} deriving (Show) -- platform-specific JSON encoding (used in API) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 4aacc7e677..e1bb3ed6f3 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -16,8 +16,7 @@ module Simplex.Chat.Store.Connections getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, getConnectionsToSubscribe, - unsetConnectionToSubscribe, - deleteConnectionRecord, + unsetConnectionToSubscribe ) where @@ -113,20 +112,22 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do SELECT c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, - c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn preparedContact = toPreparedContact preparedContactRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + groupDirectInv = toGroupDirectInvitation groupDirectInvRow + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do gm <- @@ -270,7 +271,3 @@ getConnectionsToSubscribe db vr = do unsetConnectionToSubscribe :: DB.Connection -> IO () unsetConnectionToSubscribe db = DB.execute_ db "UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1" - -deleteConnectionRecord :: DB.Connection -> User -> Int64 -> IO () -deleteConnectionRecord db User {userId} cId = do - DB.execute db "DELETE FROM connections WHERE user_id = ? AND connection_id = ?" (userId, cId) diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 4de4a300f2..3f41abb29f 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -111,7 +111,8 @@ createOrUpdateContactRequest -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 6fd064d853..321177459c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -264,7 +264,8 @@ getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -804,6 +805,7 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc contactRequestId = Nothing, contactGroupMemberId = Nothing, contactGrpInvSent = False, + groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, @@ -854,7 +856,8 @@ getContact_ db vr user@User {userId} contactId deleted = do -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d76554ef29..b4c3e1ace4 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -136,6 +136,9 @@ module Simplex.Chat.Store.Groups setContactGrpInvSent, createMemberContactInvited, updateMemberContactInvited, + createMemberContactConn, + getMemberContactInvited, + setMemberContactStartedConnection, resetMemberContactFields, updateMemberProfile, updateContactMemberProfile, @@ -201,8 +204,8 @@ import Database.SQLite.Simple.QQ (sql) type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -2583,7 +2586,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do @@ -2606,23 +2609,17 @@ setContactGrpInvSent db Contact {contactId} xGrpDirectInvSent = do "UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contact_id = ?" (BI xGrpDirectInvSent, currentTs, contactId) -createMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> GroupMember -> Connection -> SubscriptionMode -> IO (Contact, GroupMember) +createMemberContactInvited :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupDirectInvitation -> IO (ContactId, GroupMember) createMemberContactInvited db - user@User {userId, profile = LocalProfile {preferences}} - connIds + User {userId, profile = LocalProfile {preferences}} gInfo - m@GroupMember {localDisplayName = memberLDN, memberProfile, memberContactProfileId} - mConn - subMode = do + m@GroupMember {localDisplayName = memberLDN, memberContactProfileId} + GroupDirectInvitation {groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, groupDirectInvStartedConnection} = do currentTs <- liftIO getCurrentTime let userPreferences = fromMaybe emptyChatPrefs $ incognitoMembershipProfile gInfo >> preferences contactId <- createContactUpdateMember currentTs userPreferences - ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode - let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} - m' = m {memberContactId = Just contactId} - pure (mCt', m') + pure (contactId, m {memberContactId = Just contactId}) where createContactUpdateMember :: UTCTime -> Preferences -> IO ContactId createContactUpdateMember currentTs userPreferences = do @@ -2631,10 +2628,12 @@ createMemberContactInvited [sql| INSERT INTO contacts ( user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + grp_direct_inv_link, grp_direct_inv_from_group_id, grp_direct_inv_from_group_member_id, grp_direct_inv_from_member_conn_id, grp_direct_inv_started_connection, created_at, updated_at, chat_ts - ) VALUES (?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, memberLDN, memberContactProfileId, BI True, userPreferences, BI True) + :. (groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, BI groupDirectInvStartedConnection) :. (currentTs, currentTs, currentTs) ) contactId <- insertedRowId db @@ -2644,14 +2643,26 @@ createMemberContactInvited (contactId, currentTs, memberContactProfileId) pure contactId -updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> Connection -> Contact -> SubscriptionMode -> ExceptT StoreError IO Contact -updateMemberContactInvited _ _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName -updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = Just oldContactConn} subMode = liftIO $ do - updateConnectionStatus db oldContactConn ConnDeleted - activeConn' <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode - ct' <- updateContactStatus db user ct CSActive - ct'' <- resetMemberContactFields db ct' - pure (ct'' :: Contact) {activeConn = Just activeConn'} +updateMemberContactInvited :: DB.Connection -> User -> Contact -> GroupDirectInvitation -> ExceptT StoreError IO () +updateMemberContactInvited _ _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName +updateMemberContactInvited db user Contact {contactId, activeConn = Just oldContactConn} groupDirectInv = liftIO $ do + deleteConnectionRecord db user (dbConnId oldContactConn) + updateMemberContactFields groupDirectInv + where + -- - reset status to active (in case contact was deleted) + -- - reset fields used for sending invitation + -- - set fields used for accepting invitation + updateMemberContactFields GroupDirectInvitation {groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, groupDirectInvStartedConnection} = + DB.execute + db + [sql| + UPDATE contacts + SET contact_status = ?, + contact_group_member_id = NULL, contact_grp_inv_sent = 0, + grp_direct_inv_link = ?, grp_direct_inv_from_group_id = ?, grp_direct_inv_from_group_member_id = ?, grp_direct_inv_from_member_conn_id = ?, grp_direct_inv_started_connection = ? + WHERE contact_id = ? + |] + (CSActive, groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, BI groupDirectInvStartedConnection, contactId) resetMemberContactFields :: DB.Connection -> Contact -> IO Contact resetMemberContactFields db ct@Contact {contactId} = do @@ -2666,13 +2677,15 @@ resetMemberContactFields db ct@Contact {contactId} = do (currentTs, contactId) pure ct {contactGroupMemberId = Nothing, contactGrpInvSent = False, updatedAt = currentTs} -createMemberContactConn_ :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> Connection -> ContactId -> SubscriptionMode -> IO Connection -createMemberContactConn_ +createMemberContactConn :: DB.Connection -> User -> ConnId -> Maybe CommandId -> GroupInfo -> Connection -> ConnStatus -> ContactId -> SubscriptionMode -> IO Int64 +createMemberContactConn db user@User {userId} - (cmdId, acId) + acId + cmdId_ gInfo - _memberConn@Connection {connLevel, connChatVersion, peerChatVRange = peerChatVRange@(VersionRange minV maxV)} + _memberConn@Connection {connLevel, connChatVersion, peerChatVRange = VersionRange minV maxV} + connStatus contactId subMode = do currentTs <- liftIO getCurrentTime @@ -2685,38 +2698,31 @@ createMemberContactConn_ conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) + ( (userId, acId, connLevel, connStatus, ConnContact, contactId, customUserProfileId) :. (connChatVersion, minV, maxV, currentTs, currentTs, BI (subMode == SMOnlyCreate)) ) connId <- insertedRowId db - setCommandConnId db user cmdId connId - pure - Connection - { connId, - agentConnId = AgentConnId acId, - connChatVersion, - peerChatVRange, - connType = ConnContact, - contactConnInitiated = False, - entityId = Just contactId, - viaContact = Nothing, - viaUserContactLink = Nothing, - viaGroupLink = False, - groupLinkId = Nothing, - xContactId = Nothing, - customUserProfileId, - connLevel, - connStatus = ConnJoined, - localAlias = "", - createdAt = currentTs, - connectionCode = Nothing, - pqSupport = PQSupportOff, - pqEncryption = PQEncOff, - pqSndEnabled = Nothing, - pqRcvEnabled = Nothing, - authErrCounter = 0, - quotaErrCounter = 0 - } + forM_ cmdId_ $ \cmdId -> setCommandConnId db user cmdId connId + pure connId + +getMemberContactInvited :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, Connection, Contact, GroupDirectInvitation) +getMemberContactInvited db vr user contactId = do + ct@Contact {groupDirectInv = groupDirectInv_} <- getContact db vr user contactId + case groupDirectInv_ of + Just groupDirectInv@GroupDirectInvitation {fromGroupId_ = Just groupId, fromGroupMemberId_ = Just _gmId, fromGroupMemberConnId_ = Just mConnId} -> do + g <- getGroupInfo db vr user groupId + mConn <- getConnectionById db vr user mConnId + pure (g, mConn, ct, groupDirectInv) + _ -> + throwError $ SEMemberContactGroupMemberNotFound contactId + +setMemberContactStartedConnection :: DB.Connection -> Contact -> IO () +setMemberContactStartedConnection db Contact {contactId} = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE contacts SET grp_direct_inv_started_connection = ?, updated_at = ? WHERE contact_id = ?" + (BI True, currentTs, contactId) updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember updateMemberProfile db user@User {userId} m p' diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index b5b7be8adb..8b89de6a5c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -13,6 +13,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250702_contact_requests_remove_ import Simplex.Chat.Store.Postgres.Migrations.M20250704_groups_conn_link_prepared_connection import Simplex.Chat.Store.Postgres.Migrations.M20250709_profile_short_descr import Simplex.Chat.Store.Postgres.Migrations.M20250721_indexes +import Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -25,7 +26,8 @@ schemaMigrations = ("20250702_contact_requests_remove_cascade_delete", m20250702_contact_requests_remove_cascade_delete, Just down_m20250702_contact_requests_remove_cascade_delete), ("20250704_groups_conn_link_prepared_connection", m20250704_groups_conn_link_prepared_connection, Just down_m20250704_groups_conn_link_prepared_connection), ("20250709_profile_short_descr", m20250709_profile_short_descr, Just down_m20250709_profile_short_descr), - ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes) + ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes), + ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250729_member_contact_requests.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250729_member_contact_requests.hs new file mode 100644 index 0000000000..b886184331 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250729_member_contact_requests.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250729_member_contact_requests :: Text +m20250729_member_contact_requests = + T.pack + [r| +ALTER TABLE contacts ADD COLUMN grp_direct_inv_link BYTEA; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_from_group_id BIGINT REFERENCES groups(group_id) ON DELETE SET NULL; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_from_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE SET NULL; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_from_member_conn_id BIGINT REFERENCES connections(connection_id) ON DELETE SET NULL; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_started_connection SMALLINT NOT NULL DEFAULT 0; + +CREATE INDEX idx_contacts_grp_direct_inv_from_group_id ON contacts(grp_direct_inv_from_group_id); +CREATE INDEX idx_contacts_grp_direct_inv_from_group_member_id ON contacts(grp_direct_inv_from_group_member_id); +CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON contacts(grp_direct_inv_from_member_conn_id); + +ALTER TABLE users ADD COLUMN auto_accept_member_contacts SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20250729_member_contact_requests :: Text +down_m20250729_member_contact_requests = + T.pack + [r| +ALTER TABLE users DROP COLUMN auto_accept_member_contacts; + +DROP INDEX idx_contacts_grp_direct_inv_from_group_id; +DROP INDEX idx_contacts_grp_direct_inv_from_group_member_id; +DROP INDEX idx_contacts_grp_direct_inv_from_member_conn_id; + +ALTER TABLE contacts DROP COLUMN grp_direct_inv_link; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_from_group_id; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_from_group_member_id; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_from_member_conn_id; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_started_connection; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index b236093a79..c2d26df4bb 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -409,7 +409,12 @@ CREATE TABLE test_chat_schema.contacts ( conn_short_link_to_connect bytea, welcome_shared_msg_id bytea, request_shared_msg_id bytea, - contact_request_id bigint + contact_request_id bigint, + grp_direct_inv_link bytea, + grp_direct_inv_from_group_id bigint, + grp_direct_inv_from_group_member_id bigint, + grp_direct_inv_from_member_conn_id bigint, + grp_direct_inv_started_connection smallint DEFAULT 0 NOT NULL ); @@ -1126,7 +1131,8 @@ CREATE TABLE test_chat_schema.users ( send_rcpts_small_groups smallint DEFAULT 0 NOT NULL, user_member_profile_updated_at timestamp with time zone, ui_themes text, - active_order bigint DEFAULT 0 NOT NULL + active_order bigint DEFAULT 0 NOT NULL, + auto_accept_member_contacts smallint DEFAULT 0 NOT NULL ); @@ -1808,6 +1814,18 @@ CREATE INDEX idx_contacts_contact_request_id ON test_chat_schema.contacts USING +CREATE INDEX idx_contacts_grp_direct_inv_from_group_id ON test_chat_schema.contacts USING btree (grp_direct_inv_from_group_id); + + + +CREATE INDEX idx_contacts_grp_direct_inv_from_group_member_id ON test_chat_schema.contacts USING btree (grp_direct_inv_from_group_member_id); + + + +CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON test_chat_schema.contacts USING btree (grp_direct_inv_from_member_conn_id); + + + CREATE INDEX idx_contacts_via_group ON test_chat_schema.contacts USING btree (via_group); @@ -2344,6 +2362,21 @@ ALTER TABLE ONLY test_chat_schema.contacts +ALTER TABLE ONLY test_chat_schema.contacts + ADD CONSTRAINT contacts_grp_direct_inv_from_group_id_fkey FOREIGN KEY (grp_direct_inv_from_group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY test_chat_schema.contacts + ADD CONSTRAINT contacts_grp_direct_inv_from_group_member_id_fkey FOREIGN KEY (grp_direct_inv_from_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY test_chat_schema.contacts + ADD CONSTRAINT contacts_grp_direct_inv_from_member_conn_id_fkey FOREIGN KEY (grp_direct_inv_from_member_conn_id) REFERENCES test_chat_schema.connections(connection_id) ON DELETE SET NULL; + + + ALTER TABLE ONLY test_chat_schema.contacts ADD CONSTRAINT contacts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 1cceaf8e8c..b74705fd53 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -40,6 +40,7 @@ module Simplex.Chat.Store.Profiles updateAllContactReceipts, updateUserContactReceipts, updateUserGroupReceipts, + updateUserAutoAcceptMemberContacts, updateUserProfile, setUserProfileContactLink, getUserContactProfiles, @@ -135,11 +136,12 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe let showNtfs = True sendRcptsContacts = True sendRcptsSmallGroups = True + autoAcceptMemberContacts = False order <- getNextActiveOrder db DB.execute db - "INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?)" - (auId, displayName, BI activeUser, order, BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, currentTs, currentTs) + "INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?,?)" + (auId, displayName, BI activeUser, order, BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, currentTs, currentTs) userId <- insertedRowId db DB.execute db @@ -156,7 +158,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, shortDescr, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) + pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, shortDescr, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -291,6 +293,10 @@ updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOv DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (BI enable, userId) when clearOverrides $ DB.execute_ db "UPDATE groups SET send_rcpts = NULL" +updateUserAutoAcceptMemberContacts :: DB.Connection -> User -> Bool -> IO () +updateUserAutoAcceptMemberContacts db User {userId} autoAccept = + DB.execute db "UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ?" (BI autoAccept, userId) + updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 47f1f5c041..58035642ec 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -136,6 +136,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250702_contact_requests_remove_ca import Simplex.Chat.Store.SQLite.Migrations.M20250704_groups_conn_link_prepared_connection import Simplex.Chat.Store.SQLite.Migrations.M20250709_profile_short_descr import Simplex.Chat.Store.SQLite.Migrations.M20250721_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -271,7 +272,8 @@ schemaMigrations = ("20250702_contact_requests_remove_cascade_delete", m20250702_contact_requests_remove_cascade_delete, Just down_m20250702_contact_requests_remove_cascade_delete), ("20250704_groups_conn_link_prepared_connection", m20250704_groups_conn_link_prepared_connection, Just down_m20250704_groups_conn_link_prepared_connection), ("20250709_profile_short_descr", m20250709_profile_short_descr, Just down_m20250709_profile_short_descr), - ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes) + ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes), + ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250729_member_contact_requests.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250729_member_contact_requests.hs new file mode 100644 index 0000000000..0488af2e60 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250729_member_contact_requests.hs @@ -0,0 +1,38 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250729_member_contact_requests :: Query +m20250729_member_contact_requests = + [sql| +ALTER TABLE contacts ADD COLUMN grp_direct_inv_link BLOB; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_from_group_id INTEGER REFERENCES groups(group_id) ON DELETE SET NULL; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_from_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_from_member_conn_id INTEGER REFERENCES connections(connection_id) ON DELETE SET NULL; +ALTER TABLE contacts ADD COLUMN grp_direct_inv_started_connection INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_contacts_grp_direct_inv_from_group_id ON contacts(grp_direct_inv_from_group_id); +CREATE INDEX idx_contacts_grp_direct_inv_from_group_member_id ON contacts(grp_direct_inv_from_group_member_id); +CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON contacts(grp_direct_inv_from_member_conn_id); + +ALTER TABLE users ADD COLUMN auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0; +|] + +down_m20250729_member_contact_requests :: Query +down_m20250729_member_contact_requests = + [sql| +ALTER TABLE users DROP COLUMN auto_accept_member_contacts; + +DROP INDEX idx_contacts_grp_direct_inv_from_group_id; +DROP INDEX idx_contacts_grp_direct_inv_from_group_member_id; +DROP INDEX idx_contacts_grp_direct_inv_from_member_conn_id; + +ALTER TABLE contacts DROP COLUMN grp_direct_inv_link; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_from_group_id; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_from_group_member_id; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_from_member_conn_id; +ALTER TABLE contacts DROP COLUMN grp_direct_inv_started_connection; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 2ae6dc3a76..c20d8b7a33 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -198,7 +198,8 @@ Query: -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -342,8 +343,9 @@ SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO contacts ( user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + grp_direct_inv_link, grp_direct_inv_from_group_id, grp_direct_inv_from_group_member_id, grp_direct_inv_from_member_conn_id, grp_direct_inv_started_connection, created_at, updated_at, chat_ts - ) VALUES (?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) @@ -386,7 +388,8 @@ Query: SELECT c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, - c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 @@ -936,7 +939,8 @@ Query: -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -1439,6 +1443,16 @@ SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) LIST SUBQUERY 1 SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contacts + SET contact_status = ?, + contact_group_member_id = NULL, contact_grp_inv_sent = 0, + grp_direct_inv_link = ?, grp_direct_inv_from_group_id = ?, grp_direct_inv_from_group_member_id = ?, grp_direct_inv_from_member_conn_id = ?, grp_direct_inv_started_connection = ? + WHERE contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contacts SET local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -1568,7 +1582,8 @@ Query: -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -3720,6 +3735,7 @@ SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (conn SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_member_conn_id (grp_direct_inv_from_member_conn_id=?) Query: DELETE FROM connections WHERE connection_id IN ( @@ -3738,6 +3754,7 @@ SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (conn SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_member_conn_id (grp_direct_inv_from_member_conn_id=?) Query: DELETE FROM connections WHERE connection_id IN ( @@ -3756,6 +3773,7 @@ SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (conn SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_member_conn_id (grp_direct_inv_from_member_conn_id=?) Query: DELETE FROM connections WHERE connection_id IN ( @@ -3774,6 +3792,7 @@ SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (conn SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_member_conn_id (grp_direct_inv_from_member_conn_id=?) Query: DELETE FROM contact_profiles @@ -5114,7 +5133,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5126,7 +5145,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5139,7 +5158,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5152,7 +5171,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5166,7 +5185,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5179,7 +5198,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5192,7 +5211,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5205,7 +5224,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5218,7 +5237,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5309,9 +5328,6 @@ Error: SQLite3 returned ErrorError while attempting to perform prepare "explain Query: CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT) Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)": table temp_delete_members already exists -Query: DELETE FROM app_settings -Plan: - Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ? Plan: SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?) @@ -5443,6 +5459,7 @@ SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (conn SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_member_conn_id (grp_direct_inv_from_member_conn_id=?) Query: DELETE FROM connections WHERE user_id = ? AND group_member_id = ? Plan: @@ -5451,6 +5468,7 @@ SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (conn SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_member_conn_id (grp_direct_inv_from_member_conn_id=?) Query: DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5524,6 +5542,7 @@ SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_membe SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? @@ -5548,6 +5567,7 @@ SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_membe SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: DELETE FROM groups WHERE user_id = ? AND group_id = ? @@ -5564,6 +5584,7 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_business_group SEARCH user_contact_links USING COVERING INDEX idx_user_contact_links_group_id (group_id=?) SEARCH files USING COVERING INDEX idx_files_group_id (group_id=?) SEARCH group_members USING COVERING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_id (grp_direct_inv_from_group_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_via_group (via_group=?) Query: DELETE FROM messages WHERE connection_id = ? @@ -5696,9 +5717,6 @@ Plan: Query: DROP TABLE temp_delete_members Plan: -Query: INSERT INTO app_settings (app_settings) VALUES (?) -Plan: - Query: INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?) Plan: @@ -5798,7 +5816,7 @@ Plan: Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?) +Query: INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?,?) Plan: Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) @@ -6142,6 +6160,10 @@ Query: UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE u Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contacts SET grp_direct_inv_started_connection = ?, updated_at = ? WHERE contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) @@ -6358,6 +6380,10 @@ Query: UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE users SET contact_id = ? WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 322814385f..91effef7c1 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -37,7 +37,8 @@ CREATE TABLE users( send_rcpts_small_groups INTEGER NOT NULL DEFAULT 0, user_member_profile_updated_at TEXT, ui_themes TEXT, - active_order INTEGER NOT NULL DEFAULT 0, -- 1 for active user + active_order INTEGER NOT NULL DEFAULT 0, + auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT @@ -85,6 +86,11 @@ CREATE TABLE contacts( welcome_shared_msg_id BLOB, request_shared_msg_id BLOB, contact_request_id INTEGER REFERENCES contact_requests ON DELETE SET NULL, + grp_direct_inv_link BLOB, + grp_direct_inv_from_group_id INTEGER REFERENCES groups(group_id) ON DELETE SET NULL, + grp_direct_inv_from_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL, + grp_direct_inv_from_member_conn_id INTEGER REFERENCES connections(connection_id) ON DELETE SET NULL, + grp_direct_inv_started_connection INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -1085,3 +1091,12 @@ CREATE INDEX idx_chat_items_group_scope_stats_all ON chat_items( chat_item_id, user_mention ); +CREATE INDEX idx_contacts_grp_direct_inv_from_group_id ON contacts( + grp_direct_inv_from_group_id +); +CREATE INDEX idx_contacts_grp_direct_inv_from_group_member_id ON contacts( + grp_direct_inv_from_group_member_id +); +CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON contacts( + grp_direct_inv_from_member_conn_id +); diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 72282c8436..3f39fdeedb 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -457,19 +457,22 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Maybe SharedMsgId, Maybe SharedMsgId) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) + +type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, localAlias} activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito preparedContact = toPreparedContact preparedContactRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + groupDirectInv = toGroupDirectInvitation groupDirectInvRow + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} toPreparedContact :: PreparedContactRow -> Maybe PreparedContact toPreparedContact (connFullLink, connShortLink, welcomeSharedMsgId, requestSharedMsgId) = @@ -482,6 +485,11 @@ toACreatedConnLink_ (Just (ACR m cr)) csl = case csl of Nothing -> Just $ ACCL m $ CCLink cr Nothing Just (ACSL m' l) -> (\Refl -> ACCL m $ CCLink cr (Just l)) <$> testEquality m m' +toGroupDirectInvitation :: GroupDirectInvitationRow -> Maybe GroupDirectInvitation +toGroupDirectInvitation (Nothing, _, _, _, _) = Nothing +toGroupDirectInvitation (Just groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, BI groupDirectInvStartedConnection) = + Just $ GroupDirectInvitation {groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, groupDirectInvStartedConnection} + getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile getProfileById db userId profileId = ExceptT . firstRow rowToLocalProfile (SEProfileNotFound profileId) $ @@ -506,15 +514,15 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User -toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, displayName, fullName, shortDescr, image, contactLink, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt, uiThemes} +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User +toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, displayName, fullName, shortDescr, image, contactLink, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts = BoolDef autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences @@ -786,3 +794,7 @@ setViaGroupLinkHash db groupId connId = WHERE group_id = ? |] (connId, groupId) + +deleteConnectionRecord :: DB.Connection -> User -> Int64 -> IO () +deleteConnectionRecord db User {userId} cId = do + DB.execute db "DELETE FROM connections WHERE user_id = ? AND connection_id = ?" (userId, cId) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b36a7e991f..fb36f11790 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -132,6 +132,7 @@ data User = User showNtfs :: Bool, sendRcptsContacts :: Bool, sendRcptsSmallGroups :: Bool, + autoAcceptMemberContacts :: BoolDef, userMemberProfileUpdatedAt :: Maybe UTCTime, uiThemes :: Maybe UIThemeEntityOverrides } @@ -191,8 +192,14 @@ data Contact = Contact chatTs :: Maybe UTCTime, preparedContact :: Maybe PreparedContact, contactRequestId :: Maybe Int64, + -- contactGroupMemberId + contactGrpInvSent are used in conjunction for making connection request + -- to a group member via direct message feature contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool, + -- groupDirectInv is used for accepting connection request made via direct message feature by a group member + -- when auto-accept is disabled - this is the opposite side of contactGroupMemberId + contactGrpInvSent + -- (there is no hidden meaning in naming inconsistency) + groupDirectInv :: Maybe GroupDirectInvitation, chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, @@ -212,6 +219,15 @@ data PreparedContact = PreparedContact } deriving (Eq, Show) +data GroupDirectInvitation = GroupDirectInvitation + { groupDirectInvLink :: ConnReqInvitation, + fromGroupId_ :: Maybe GroupId, + fromGroupMemberId_ :: Maybe GroupMemberId, + fromGroupMemberConnId_ :: Maybe Int64, + groupDirectInvStartedConnection :: Bool + } + deriving (Eq, Show) + newtype SharedMsgId = SharedMsgId ByteString deriving (Eq, Show) deriving newtype (FromField) @@ -1589,6 +1605,9 @@ data Connection = Connection } deriving (Eq, Show) +dbConnId :: Connection -> Int64 +dbConnId Connection {connId} = connId + connReady :: Connection -> Bool connReady Connection {connStatus} = connStatus == ConnReady || connStatus == ConnSndReady @@ -1965,8 +1984,8 @@ instance ToJSON ChatVersionRange where -- This type is needed for backward compatibility of new remote controller with old remote host. -- See CONTRIBUTING.md -newtype BoolDef = BoolDef Bool - deriving newtype (Show, ToJSON) +newtype BoolDef = BoolDef {isTrue :: Bool} + deriving newtype (Eq, Show, ToJSON) instance FromJSON BoolDef where parseJSON v = BoolDef <$> parseJSON v @@ -2072,6 +2091,8 @@ $(JQ.deriveJSON defaultJSON ''FileTransferMeta) $(JQ.deriveJSON defaultJSON ''PreparedContact) +$(JQ.deriveJSON defaultJSON ''GroupDirectInvitation) + $(JQ.deriveJSON defaultJSON ''LocalFileMeta) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FT") ''FileTransfer) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 753d1a2be8..b376993beb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -242,6 +242,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] + CRMemberContactAccepted u ct -> ttyUser u ["contact " <> ttyContact' ct <> " is accepted, starting connection"] CRCallInvitations _ -> [] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] @@ -485,7 +486,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CEvtAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CEvtNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] - CEvtNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] + CEvtNewMemberContactReceivedInv u ct g m -> ttyUser u $ viewNewMemberContactReceivedInv u ct g m CEvtContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' CEvtCallInvitation RcvCallInvitation {user, contact, callType, sharedKey} -> ttyUser user $ viewCallInvitation contact callType sharedKey CEvtCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey @@ -1411,6 +1412,16 @@ viewContactsMerged c1 c2 ct' = "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] +viewNewMemberContactReceivedInv :: User -> Contact -> GroupInfo -> GroupMember -> [StyledString] +viewNewMemberContactReceivedInv user ct@Contact {localDisplayName = c} g m + | isTrue (autoAcceptMemberContacts user) = + [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] + | otherwise = + [ ttyGroup' g <> " " <> ttyMember m <> " requests to create direct contact with you", + "to accept: " <> highlight ("/accept_member_contact @" <> viewName c), + "to reject: " <> highlight ("/delete @" <> viewName c) <> " (the sender will NOT be notified)" + ] + viewContactAndMemberAssociated :: Contact -> GroupInfo -> GroupMember -> Contact -> [StyledString] viewContactAndMemberAssociated ct g m ct' = [ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m, diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 1aba3a77c6..ffac4a8f6f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -147,6 +147,7 @@ chatGroupTests = do it "share incognito profile" testMemberContactIncognito it "sends and updates profile when creating contact" testMemberContactProfileUpdate it "re-create member contact after deletion, many groups" testRecreateMemberContactManyGroups + it "manually accept contact with group member" testMemberContactAccept describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport @@ -437,6 +438,9 @@ testNewGroupIncognito :: HasCallStack => TestParams -> IO () testNewGroupIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do + bob ##> "/set accept member contacts on" + bob <## "ok" + connectUsers alice bob -- alice creates group with incognito membership @@ -846,6 +850,9 @@ testGroupDeleteInvitedContact :: HasCallStack => TestParams -> IO () testGroupDeleteInvitedContact = testChat2 aliceProfile bobProfile $ \alice bob -> do + bob ##> "/set accept member contacts on" + bob <## "ok" + connectUsers alice bob alice ##> "/g team" alice <## "group #team is created" @@ -4144,6 +4151,11 @@ testMemberContactMessage :: HasCallStack => TestParams -> IO () testMemberContactMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + bob ##> "/set accept member contacts on" + bob <## "ok" + cath ##> "/set accept member contacts on" + cath <## "ok" + createGroup3 "team" alice bob cath -- alice and bob delete contacts, connect @@ -4211,6 +4223,9 @@ testMemberContactNoMessage :: HasCallStack => TestParams -> IO () testMemberContactNoMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + cath ##> "/set accept member contacts on" + cath <## "ok" + createGroup3 "team" alice bob cath -- bob and cath connect @@ -4245,6 +4260,9 @@ testMemberContactProhibitedRepeatInv :: HasCallStack => TestParams -> IO () testMemberContactProhibitedRepeatInv = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + cath ##> "/set accept member contacts on" + cath <## "ok" + createGroup3 "team" alice bob cath bob ##> "/_create member contact #1 3" @@ -4273,6 +4291,9 @@ testMemberContactInvitedConnectionReplaced ps = do withNewTestChat ps "alice" aliceProfile $ \alice -> do withNewTestChat ps "bob" bobProfile $ \bob -> do withNewTestChat ps "cath" cathProfile $ \cath -> do + bob ##> "/set accept member contacts on" + bob <## "ok" + createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -4343,6 +4364,9 @@ testMemberContactIncognito :: HasCallStack => TestParams -> IO () testMemberContactIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + cath ##> "/set accept member contacts on" + cath <## "ok" + -- create group, bob joins incognito threadDelay 100000 alice ##> "/g team" @@ -4433,6 +4457,9 @@ testMemberContactProfileUpdate :: HasCallStack => TestParams -> IO () testMemberContactProfileUpdate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + cath ##> "/set accept member contacts on" + cath <## "ok" + createGroup3 "team" alice bob cath bob ##> "/p rob Rob" @@ -4501,6 +4528,9 @@ testRecreateMemberContactManyGroups :: HasCallStack => TestParams -> IO () testRecreateMemberContactManyGroups = testChat2 aliceProfile bobProfile $ \alice bob -> do + bob ##> "/set accept member contacts on" + bob <## "ok" + connectUsers alice bob createGroup2' "team" alice (bob, GRAdmin) False createGroup2' "club" alice (bob, GRAdmin) False @@ -4570,6 +4600,46 @@ testRecreateMemberContactManyGroups = bob <# "@alice 4" alice <# "bob> 4" +testMemberContactAccept :: HasCallStack => TestParams -> IO () +testMemberContactAccept = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + -- bob and cath connect + bob ##> "/_create member contact #1 3" + bob <## "contact for member #team cath is created" + + bob ##> "/_invite member contact @3" + bob <## "sent invitation to connect directly to member #team cath" + cath <## "#team bob requests to create direct contact with you" + cath <## "to accept: /accept_member_contact @bob" + cath <## "to reject: /delete @bob (the sender will NOT be notified)" + + cath #$> ("/_get chat @3 count=1", chat, [(0, "requested connection from group team")]) + + cath ##> "/accept_member_contact @bob" + cath <## "contact bob is accepted, starting connection" + concurrently_ + (bob <## "cath (Catherine): contact is connected") + (cath <## "bob (Bob): contact is connected") + + bob <##> cath + + -- if group is deleted, bob and cath keep contact with each other + alice ##> "/d #team" + concurrentlyN_ + [ alice <## "#team: you deleted the group", + do + bob <## "#team: alice deleted the group" + bob <## "use /d #team to delete the local copy of the group", + do + cath <## "#team: alice deleted the group" + cath <## "use /d #team to delete the local copy of the group" + ] + + bob <##> cath + testGroupMsgForward :: HasCallStack => TestParams -> IO () testGroupMsgForward = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 986cd487ce..f043e1c425 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -211,6 +211,9 @@ testMultiWordProfileNames :: HasCallStack => TestParams -> IO () testMultiWordProfileNames = testChat3 aliceProfile' bobProfile' cathProfile' $ \alice bob cath -> do + cath ##> "/set accept member contacts on" + cath <## "ok" + alice ##> "/c" inv <- getInvitation alice bob ##> ("/c " <> inv) @@ -2611,6 +2614,9 @@ testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ testGroupPrefsDirectForRole :: HasCallStack => TestParams -> IO () testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + dan ##> "/set accept member contacts on" + dan <## "ok" + createGroup3 "team" alice bob cath threadDelay 1000000 alice ##> "/set direct #team on owner" diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index 99157b127f..baf9dde8c1 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -17,10 +17,10 @@ activeUserExistsTagged :: LB.ByteString activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}}}" chatStartedSwift :: LB.ByteString chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" @@ -35,7 +35,7 @@ networkStatusesTagged :: LB.ByteString networkStatusesTagged = "{\"result\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" userJSON :: LB.ByteString -userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}" memberSubSummarySwift :: LB.ByteString memberSubSummarySwift = "{\"result\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}"