From b0ee13628ba16f93109d26e5d56294716f251dda Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 13 Jun 2025 14:38:17 +0000 Subject: [PATCH] core: change user for prepared contact or group (#5985) --- apps/ios/Shared/Model/AppAPITypes.swift | 12 + apps/ios/Shared/Model/ChatModel.swift | 11 +- apps/ios/Shared/Model/SimpleXAPI.swift | 21 +- .../Chat/ComposeMessage/ComposeView.swift | 14 +- .../ContextProfilePickerView.swift | 285 +++++++++++++++++ .../Shared/Views/NewChat/NewChatView.swift | 9 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 2 +- src/Simplex/Chat/Controller.hs | 6 +- src/Simplex/Chat/Library/Commands.hs | 26 +- src/Simplex/Chat/Store/Direct.hs | 30 ++ src/Simplex/Chat/Store/Groups.hs | 65 ++++ .../SQLite/Migrations/chat_query_plans.txt | 58 ++++ src/Simplex/Chat/View.hs | 24 ++ tests/ChatTests/Profiles.hs | 299 +++++++++++++++++- 15 files changed, 828 insertions(+), 38 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index ba7047a62e..209cf6c1e0 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -122,6 +122,8 @@ enum ChatCommand: ChatCmdProtocol { case apiConnectPlan(userId: Int64, connLink: String) case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) + case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) + case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent) case apiConnectPreparedGroup(groupId: Int64, incognito: Bool) case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink) @@ -320,6 +322,8 @@ enum ChatCommand: ChatCmdProtocol { case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))" case let .apiPrepareGroup(userId, connLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(groupShortLinkData))" + case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)" + case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)" case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito)) \(mc.cmdString)" case let .apiConnectPreparedGroup(groupId, incognito): return "/_connect group #\(groupId) incognito=\(onOff(incognito))" case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")" @@ -492,6 +496,8 @@ enum ChatCommand: ChatCmdProtocol { case .apiConnectPlan: return "apiConnectPlan" case .apiPrepareContact: return "apiPrepareContact" case .apiPrepareGroup: return "apiPrepareGroup" + case .apiChangePreparedContactUser: return "apiChangePreparedContactUser" + case .apiChangePreparedGroupUser: return "apiChangePreparedGroupUser" case .apiConnectPreparedContact: return "apiConnectPreparedContact" case .apiConnectPreparedGroup: return "apiConnectPreparedGroup" case .apiConnect: return "apiConnect" @@ -743,6 +749,8 @@ enum ChatResponse1: Decodable, ChatAPIResult { case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan) case newPreparedContact(user: UserRef, contact: Contact) case newPreparedGroup(user: UserRef, groupInfo: GroupInfo) + case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact) + case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo) case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection) case startedConnectionToContact(user: UserRef, contact: Contact) @@ -786,6 +794,8 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .connectionPlan: "connectionPlan" case .newPreparedContact: "newPreparedContact" case .newPreparedGroup: "newPreparedContact" + case .contactUserChanged: "contactUserChanged" + case .groupUserChanged: "groupUserChanged" case .sentConfirmation: "sentConfirmation" case .sentInvitation: "sentInvitation" case .startedConnectionToContact: "startedConnectionToContact" @@ -865,6 +875,8 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") case let .newPreparedContact(u, contact): return withUser(u, String(describing: contact)) case let .newPreparedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .contactUserChanged(u, fromContact, newUser, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\nnewUserId: \(String(describing: newUser.userId))\ntoContact: \(String(describing: toContact))") + case let .groupUserChanged(u, fromGroup, newUser, toGroup): return withUser(u, "fromGroup: \(String(describing: fromGroup))\nnewUserId: \(String(describing: newUser.userId))\ntoGroup: \(String(describing: toGroup))") case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .startedConnectionToContact(u, contact): return withUser(u, String(describing: contact)) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 51530bca80..563617e64a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -564,8 +564,15 @@ final class ChatModel: ObservableObject { } } - func updateChats(_ newChats: [ChatData]) { - chats = newChats.map { Chat($0) } + func updateChats(_ newChats: [ChatData], keepingChatId: String? = nil) { + if let keepingChatId, + let chatToKeep = getChat(keepingChatId), + let i = newChats.firstIndex(where: { $0.id == keepingChatId }) { + let remainingNewChats = Array(newChats[.. P func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection { let r: ChatResponse1 = try await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId)) - if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection} throw r.unexpected } @@ -1017,6 +1016,18 @@ func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLi throw r.unexpected } +func apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) async throws -> Contact { + let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedContactUser(contactId: contactId, newUserId: newUserId)) + if case let .contactUserChanged(_, _, _, toContact) = r {return toContact} + throw r.unexpected +} + +func apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) async throws -> GroupInfo { + let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedGroupUser(groupId: groupId, newUserId: newUserId)) + if case let .groupUserChanged(_, _, _, toGroup) = r {return toGroup} + throw r.unexpected +} + func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent) async throws -> Contact { let r: ChatResponse1 = try await chatSendCmd(.apiConnectPreparedContact(contactId: contactId, incognito: incognito, msg: msg)) if case let .startedConnectionToContact(_, contact) = r { return contact } @@ -1969,7 +1980,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws { try getUserChatData() } -func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws { +func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws { let currentUser = if let userId = userId { try await apiSetActiveUserAsync(userId, viewPwd: viewPwd) } else { @@ -1981,7 +1992,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?) async throws { m.currentUser = currentUser m.users = users } - try await getUserChatDataAsync() + try await getUserChatDataAsync(keepingChatId: keepingChatId) await MainActor.run { if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) { invitation.user = currentUser @@ -2003,7 +2014,7 @@ func getUserChatData() throws { tm.updateChatTags(m.chats) } -private func getUserChatDataAsync() async throws { +private func getUserChatDataAsync(keepingChatId: String?) async throws { let m = ChatModel.shared let tm = ChatTagsModel.shared if m.currentUser != nil { @@ -2014,7 +2025,7 @@ private func getUserChatDataAsync() async throws { await MainActor.run { m.userAddress = userAddress m.chatItemTTL = chatItemTTL - m.updateChats(chats) + m.updateChats(chats, keepingChatId: keepingChatId) tm.activeFilter = nil tm.userTags = tags tm.updateChatTags(m.chats) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 0aafa261a1..1dd945696b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -350,6 +350,14 @@ struct ComposeView: View { var body: some View { VStack(spacing: 0) { Divider() + if (chat.chatInfo.contact?.nextConnectPrepared ?? false) || (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false), + let user = chatModel.currentUser { + ContextProfilePickerView( + chat: chat, + selectedUser: user + ) + } + if let contact = chat.chatInfo.contact, contact.nextAcceptContactRequest, let contactRequestId = contact.contactRequestId { @@ -692,8 +700,7 @@ struct ComposeView: View { private func sendConnectPreparedContact() async { do { let mc = checkLinkPreview() - // TODO [short links] allow to choose incognito, different user profile (as "compose context") - let contact = try await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: false, msg: mc) + let contact = try await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognitoGroupDefault.get(), msg: mc) await MainActor.run { self.chatModel.updateContact(contact) clearState() @@ -706,8 +713,7 @@ struct ComposeView: View { private func connectPreparedGroup() async { do { - // TODO [short links] allow to choose incognito, different user profile (as "compose context") - let groupInfo = try await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: false) + let groupInfo = try await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognitoGroupDefault.get()) await MainActor.run { self.chatModel.updateGroup(groupInfo) clearState() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift new file mode 100644 index 0000000000..2e2a7ab6c4 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -0,0 +1,285 @@ +// +// ContextProfilePickerView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 13.06.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +let USER_ROW_SIZE: CGFloat = 60 +let MAX_VISIBLE_USER_ROWS: CGFloat = 4.8 + +struct ContextProfilePickerView: View { + @ObservedObject var chat: Chat + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State var selectedUser: User + @State private var users: [User] = [] + @State private var listExpanded = false + @State private var expandedListReady = false + @State private var showIncognitoSheet = false + + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + + var body: some View { + viewBody() + .onAppear { + users = chatModel.users + .map { $0.user } + .filter { u in u.activeUser || !u.hidden } + } + .sheet(isPresented: $showIncognitoSheet) { + IncognitoHelp() + } + } + + private func viewBody() -> some View { + Group { + if !listExpanded { + currentSelection() + } else { + profilePicker() + } + } + .padding(.bottom, -8) + } + + private func currentSelection() -> some View { + VStack(spacing: 0) { + HStack { + Text("Share profile") + .font(.callout) + .foregroundColor(theme.colors.secondary) + Spacer() + } + .padding(.top, 10) + .padding(.bottom, -4) + .padding(.leading, 12) + .padding(.trailing) + + if incognitoDefault { + incognitoOption() + } else { + profilerPickerUserOption(selectedUser) + } + } + } + + private func profilePicker() -> some View { + ScrollViewReader { proxy in + Group { + if expandedListReady { + let scroll = ScrollView { + LazyVStack(spacing: 0) { + let otherUsers = users + .filter { u in u.userId != selectedUser.userId } + .sorted(using: KeyPathComparator(\.activeOrder)) + ForEach(otherUsers) { p in + profilerPickerUserOption(p) + .contentShape(Rectangle()) + Divider() + .padding(.leading) + .padding(.leading, 48) + } + + if incognitoDefault { + profilerPickerUserOption(selectedUser) + .contentShape(Rectangle()) + Divider() + .padding(.leading) + .padding(.leading, 48) + + incognitoOption() + .contentShape(Rectangle()) + .id("BOTTOM_ANCHOR") + } else { + incognitoOption() + .contentShape(Rectangle()) + Divider() + .padding(.leading) + .padding(.leading, 48) + + profilerPickerUserOption(selectedUser) + .contentShape(Rectangle()) + .id("BOTTOM_ANCHOR") + } + } + } + .frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 1))) // + 1 for incognito + .onAppear { + DispatchQueue.main.async { + withAnimation(nil) { + proxy.scrollTo("BOTTOM_ANCHOR", anchor: .bottom) + } + } + } + .onDisappear { + expandedListReady = false + } + + if #available(iOS 16.0, *) { + scroll.scrollDismissesKeyboard(.never) + } else { + scroll + } + } else { + // Keep showing current selection to avoid flickering of scroll to bottom + currentSelection() + .onAppear { + // Delay rendering of expanded profile list + DispatchQueue.main.async { + expandedListReady = true + } + } + } + } + } + } + + private func profilerPickerUserOption(_ user: User) -> some View { + Button { + if selectedUser == user { + if !incognitoDefault { + listExpanded.toggle() + } else { + incognitoDefault = false + listExpanded = false + } + } else if selectedUser != user { + changeProfile(user) + } + } label: { + HStack { + ProfileImage(imageStr: user.image, size: 38) + Text(user.chatViewName) + .fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + + Spacer() + + if selectedUser == user && !incognitoDefault { + if listExpanded { + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(theme.colors.secondary) + .opacity(0.7) + } else { + Image(systemName: "chevron.up") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(theme.colors.secondary) + .opacity(0.7) + } + } + } + .padding(.leading, 12) + .padding(.trailing) + .frame(height: USER_ROW_SIZE) + } + } + + private func changeProfile(_ newUser: User) { + Task { + do { + if let contact = chat.chatInfo.contact { + let updatedContact = try await apiChangePreparedContactUser(contactId: contact.contactId, newUserId: newUser.userId) + await MainActor.run { + selectedUser = newUser + incognitoDefault = false + listExpanded = false + chatModel.updateContact(updatedContact) + } + } else if let groupInfo = chat.chatInfo.groupInfo { + let updatedGroupInfo = try await apiChangePreparedGroupUser(groupId: groupInfo.groupId, newUserId: newUser.userId) + await MainActor.run { + selectedUser = newUser + incognitoDefault = false + listExpanded = false + chatModel.updateGroup(updatedGroupInfo) + } + } + do { + try await changeActiveUserAsync_(newUser.userId, viewPwd: nil, keepingChatId: chat.id) + } catch { + await MainActor.run { + showAlert( + NSLocalizedString("Error switching profile", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile.", comment: "alert message"), newUser.chatViewName) + ) + } + } + } catch let error { + await MainActor.run { + if let currentUser = chatModel.currentUser { + selectedUser = currentUser + } + showAlert( + NSLocalizedString("Error changing chat profile", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private func incognitoOption() -> some View { + Button { + if incognitoDefault { + listExpanded.toggle() + } else { + incognitoDefault = true + listExpanded = false + } + } label : { + HStack { + incognitoProfileImage() + Text("Incognito") + .fontWeight(incognitoDefault ? .medium : .regular) + .foregroundColor(theme.colors.onBackground) + Image(systemName: "info.circle") + .font(.system(size: 16)) + .foregroundColor(theme.colors.primary) + .onTapGesture { + showIncognitoSheet = true + } + + Spacer() + + if incognitoDefault { + if listExpanded { + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(theme.colors.secondary) + .opacity(0.7) + } else { + Image(systemName: "chevron.up") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(theme.colors.secondary) + .opacity(0.7) + } + } + } + .padding(.leading, 12) + .padding(.trailing) + .frame(height: USER_ROW_SIZE) + } + } + + private func incognitoProfileImage() -> some View { + Image(systemName: "theatermasks.fill") + .resizable() + .scaledToFit() + .frame(width: 38) + .foregroundColor(.indigo) + } +} + +#Preview { + ContextProfilePickerView( + chat: Chat.sampleData, + selectedUser: User.sampleData + ) +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index e6abdb41f8..d014177437 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -367,7 +367,6 @@ private struct ActiveProfilePicker: View { .onAppear { profiles = chatModel.users .map { $0.user } - .sorted { u, _ in u.activeUser } } .onChange(of: incognitoEnabled) { incognito in if profileSwitchStatus != .switchingIncognito { @@ -425,7 +424,7 @@ private struct ActiveProfilePicker: View { chatModel.updateContactConnection(conn) } do { - try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil ) + try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil) await MainActor.run { profileSwitchStatus = .idle dismiss() @@ -559,8 +558,10 @@ private struct ActiveProfilePicker: View { let activeProfile = filteredProfiles.first { u in u.activeUser } if let selectedProfile = activeProfile { - let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId } - + let otherProfiles = filteredProfiles + .filter { u in u.userId != activeProfile?.userId } + .sorted(using: KeyPathComparator(\.activeOrder, order: .reverse)) + if incognitoFirst { incognitoOption profilerPickerUserOption(selectedProfile) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6bc5f2e480..a4df1c64f2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -185,6 +185,7 @@ 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; 64E5E3632DF71A4E00A4D530 /* ContextContactRequestActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */; }; + 64E5E3672DFC16A900A4D530 /* ContextProfilePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */; }; 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 */; }; @@ -550,6 +551,7 @@ 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextContactRequestActionsView.swift; sourceTree = ""; }; + 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextProfilePickerView.swift; sourceTree = ""; }; 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 = ""; }; @@ -1087,6 +1089,7 @@ D72A9087294BD7A70047C86D /* NativeTextEditor.swift */, 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */, 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */, + 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -1513,6 +1516,7 @@ 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */, 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, + 64E5E3672DFC16A900A4D530 /* ContextProfilePickerView.swift in Sources */, 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */, 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3d89cafde9..f92a3fa802 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1350,8 +1350,8 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): - // TODO [short links] this will have additional statuses for pending contact requests before they are accepted if contact.sendMsgToConnect { return nil } + if contact.nextAcceptContactRequest { return ("can't send messages", nil) } if !contact.active { return ("contact deleted", nil) } if !contact.sndReady { return ("contact not ready", nil) } if contact.activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false { return ("not synchronized", nil) } diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 961373316a..37d70475a4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -450,8 +450,8 @@ data ChatCommand | APIConnectPlan UserId AConnectionLink | APIPrepareContact UserId ACreatedConnLink ContactShortLinkData | APIPrepareGroup UserId ACreatedConnLink GroupShortLinkData - | APIChangeContactUser ContactId UserId - | APIChangeGroupUser GroupId UserId + | APIChangePreparedContactUser ContactId UserId + | APIChangePreparedGroupUser GroupId UserId | APIConnectPreparedContact {contactId :: ContactId, incognito :: IncognitoEnabled, msgContent_ :: Maybe MsgContent} | APIConnectPreparedGroup GroupId IncognitoEnabled | APIConnect UserId IncognitoEnabled (Maybe ACreatedConnLink) (Maybe MsgContent) @@ -685,6 +685,8 @@ data ChatResponse | CRConnectionPlan {user :: User, connLink :: ACreatedConnLink, connectionPlan :: ConnectionPlan} | CRNewPreparedContact {user :: User, contact :: Contact} | CRNewPreparedGroup {user :: User, groupInfo :: GroupInfo} + | CRContactUserChanged {user :: User, fromContact :: Contact, newUser :: User, toContact :: Contact} + | CRGroupUserChanged {user :: User, fromGroup :: GroupInfo, newUser :: User, toGroup :: GroupInfo} | CRSentConfirmation {user :: User, connection :: PendingContactConnection} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRStartedConnectionToContact {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c94abdb5e7..828b96f032 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1760,13 +1760,21 @@ processChatCommand' vr = \case let GroupShortLinkData {groupProfile} = groupSLinkData gInfo <- withStore $ \db -> createPreparedGroup db vr user groupProfile accLink pure $ CRNewPreparedGroup user gInfo - -- TODO [short links] change prepared entity user - -- TODO - UI would call these APIs before APIConnectPrepared... APIs - -- TODO - UI to transition to new user keeping chat opened - APIChangeContactUser _contactId _newUserId -> withUser $ \_user -> do - ok_ - APIChangeGroupUser _groupId _newUserId -> withUser $ \_user -> do - ok_ + APIChangePreparedContactUser contactId newUserId -> withUser $ \user -> do + ct@Contact {connLinkToConnect} <- withFastStore $ \db -> getContact db vr user contactId + when (isNothing connLinkToConnect) $ throwCmdError "contact doesn't have link to connect" + when (isJust $ contactConn ct) $ throwCmdError "contact already has connection" + newUser <- privateGetUser newUserId + ct' <- withFastStore $ \db -> updatePreparedContactUser db vr user ct newUser + pure $ CRContactUserChanged user ct newUser ct' + APIChangePreparedGroupUser groupId newUserId -> withUser $ \user -> do + (gInfo, hostMember) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getHostMember db vr user groupId + let GroupInfo {connLinkToConnect} = gInfo + when (isNothing connLinkToConnect) $ throwCmdError "group doesn't have link to connect" + when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" + newUser <- privateGetUser newUserId + gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db vr user gInfo hostMember newUser + pure $ CRGroupUserChanged user gInfo newUser gInfo' APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do Contact {connLinkToConnect} <- withFastStore $ \db -> getContact db vr user contactId case connLinkToConnect of @@ -4424,8 +4432,8 @@ chatCommandP = "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), "/_prepare contact " *> (APIPrepareContact <$> A.decimal <* A.space <*> connLinkP <* A.space <*> jsonP), "/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP <* A.space <*> jsonP), - "/_set contact user @" *> (APIChangeContactUser <$> A.decimal <* A.space <*> A.decimal), - "/_set group user #" *> (APIChangeGroupUser <$> A.decimal <* A.space <*> A.decimal), + "/_set contact user @" *> (APIChangePreparedContactUser <$> A.decimal <* A.space <*> A.decimal), + "/_set group user #" *> (APIChangePreparedGroupUser <$> A.decimal <* A.space <*> A.decimal), "/_connect contact @" *> (APIConnectPreparedContact <$> A.decimal <*> incognitoOnOffP <*> optional (A.space *> msgContentP)), "/_connect group #" *> (APIConnectPreparedGroup <$> A.decimal <*> incognitoOnOffP), "/_connect " *> (APIAddContact <$> A.decimal <*> shortOnOffP <*> incognitoOnOffP), diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index c8e840c199..da83c89a0f 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -31,6 +31,7 @@ module Simplex.Chat.Store.Direct getConnReqContactXContactId, getContactByConnReqHash, createPreparedContact, + updatePreparedContactUser, createDirectContact, deleteContactConnections, deleteContactFiles, @@ -281,6 +282,35 @@ createPreparedContact db user@User {userId} p@Profile {preferences} connLinkToCo customData = Nothing } +updatePreparedContactUser :: DB.Connection -> VersionRangeChat -> User -> Contact -> User -> ExceptT StoreError IO Contact +updatePreparedContactUser + db + vr + user + Contact {contactId, localDisplayName = oldLDN, profile = LocalProfile {profileId, displayName}} + newUser@User {userId = newUserId} = do + ExceptT . withLocalDisplayName db newUserId displayName $ \newLDN -> runExceptT $ do + liftIO $ do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE contacts + SET user_id = ?, local_display_name = ?, updated_at = ? + WHERE contact_id = ? + |] + (newUserId, newLDN, currentTs, contactId) + DB.execute + db + [sql| + UPDATE contact_profiles + SET user_id = ?, updated_at = ? + WHERE contact_profile_id = ? + |] + (newUserId, currentTs, profileId) + safeDeleteLDN db user oldLDN + getContact db vr newUser contactId + createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do currentTs <- liftIO getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 38923575d8..d6596bbe49 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -36,6 +36,7 @@ module Simplex.Chat.Store.Groups createGroupInvitation, deleteContactCardKeepConn, createPreparedGroup, + updatePreparedGroupUser, setGroupConnLinkStartedConnection, updatePreparedUserAndHostMembersInvited, updatePreparedUserAndHostMembersRejected, @@ -607,6 +608,70 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile connLin ) insertedRowId db +updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> User -> ExceptT StoreError IO GroupInfo +updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember newUser@User {userId = newUserId} = do + currentTs <- liftIO getCurrentTime + updateGroup gInfo currentTs + liftIO $ updateMembership membership currentTs + updateHostMember hostMember currentTs + getGroupInfo db vr newUser groupId + where + updateGroup GroupInfo {localDisplayName = oldGroupLDN, groupProfile = GroupProfile {displayName = groupDisplayName}} currentTs = + ExceptT . withLocalDisplayName db newUserId groupDisplayName $ \newGroupLDN -> runExceptT $ do + liftIO $ do + DB.execute + db + [sql| + UPDATE groups + SET user_id = ?, local_display_name = ?, updated_at = ? + WHERE group_id = ? + |] + (newUserId, newGroupLDN, currentTs, groupId) + DB.execute + db + [sql| + UPDATE group_profiles + SET user_id = ?, updated_at = ? + WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE group_id = ?) + |] + (newUserId, currentTs, groupId) + safeDeleteLDN db user oldGroupLDN + updateMembership GroupMember {groupMemberId = membershipId} currentTs = + DB.execute + db + [sql| + UPDATE group_members + SET user_id = ?, local_display_name = ?, contact_id = ?, contact_profile_id = ?, updated_at = ? + WHERE group_member_id = ? + |] + (newUserId, localDisplayName' newUser, contactId' newUser, localProfileId $ profile' newUser, currentTs, membershipId) + updateHostMember + GroupMember + { groupMemberId = hostId, + localDisplayName = oldHostLDN, + memberProfile = LocalProfile {profileId = hostProfileId, displayName = hostDisplayName} + } + currentTs = + ExceptT . withLocalDisplayName db newUserId hostDisplayName $ \newHostLDN -> runExceptT $ do + liftIO $ do + DB.execute + db + [sql| + UPDATE group_members + SET user_id = ?, local_display_name = ?, updated_at = ? + WHERE group_member_id = ? + |] + (newUserId, newHostLDN, currentTs, hostId) + DB.execute + db + [sql| + UPDATE contact_profiles + SET user_id = ?, updated_at = ? + WHERE contact_profile_id = ? + |] + (newUserId, currentTs, hostProfileId) + safeDeleteLDN db user oldHostLDN + setGroupConnLinkStartedConnection :: DB.Connection -> GroupInfo -> Bool -> IO GroupInfo setGroupConnLinkStartedConnection db groupInfo@GroupInfo {groupId} connLinkStartedConnection = do currentTs <- getCurrentTime 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 f14c4f4c4f..1dd18970cd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -86,6 +86,14 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles + SET user_id = ?, updated_at = ? + WHERE contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_requests SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? @@ -136,6 +144,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET user_id = ?, local_display_name = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: INSERT INTO contact_requests (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, @@ -273,6 +289,24 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_profiles + SET user_id = ?, updated_at = ? + WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE group_id = ?) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET user_id = ?, local_display_name = ?, updated_at = ? + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE xftp_file_descriptions SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ? @@ -660,6 +694,22 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles + SET user_id = ?, updated_at = ? + WHERE contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET user_id = ?, local_display_name = ?, updated_at = ? + WHERE contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_id = ?, @@ -1348,6 +1398,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET user_id = ?, local_display_name = ?, contact_id = ?, contact_profile_id = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_profiles SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 995152da3e..4d5e22e427 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -192,6 +192,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRConnectionPlan u connLink connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan CRNewPreparedContact u c -> ttyUser u [ttyContact' c <> ": contact is prepared"] CRNewPreparedGroup u g -> ttyUser u [ttyGroup' g <> ": group is prepared"] + CRContactUserChanged u c nu c' -> ttyUser u $ viewContactUserChanged u c nu c' + CRGroupUserChanged u g nu g' -> ttyUser u $ viewGroupUserChanged u g nu g' CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRStartedConnectionToContact u c -> ttyUser u [ttyContact' c <> ": connection started"] @@ -1831,6 +1833,28 @@ viewConnectionUserChanged User {localDisplayName = n} PendingContactConnection { where cReqStr = strEncode $ simplexChatInvitation cReq +viewContactUserChanged :: User -> Contact -> User -> Contact -> [StyledString] +viewContactUserChanged + User {localDisplayName = un} + ct@Contact {localDisplayName = cn} + User {localDisplayName = un'} + Contact {localDisplayName = cn'} + | cn' /= cn = [userChangedStr <> ", new local name: " <> ttyContact cn'] + | otherwise = [userChangedStr] + where + userChangedStr = "contact " <> ttyContact' ct <> " changed from user " <> plain un <> " to user " <> plain un' + +viewGroupUserChanged :: User -> GroupInfo -> User -> GroupInfo -> [StyledString] +viewGroupUserChanged + User {localDisplayName = un} + g@GroupInfo {localDisplayName = gn} + User {localDisplayName = un'} + GroupInfo {localDisplayName = gn'} + | gn' /= gn = [userChangedStr <> ", new local name: " <> ttyGroup gn'] + | otherwise = [userChangedStr] + where + userChangedStr = "group " <> ttyGroup' g <> " changed from user " <> plain un <> " to user " <> plain un' + viewConnectionPlan :: ChatConfig -> ACreatedConnLink -> ConnectionPlan -> [StyledString] viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case CPInvitationLink ilp -> case ilp of diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index ae3db38be5..14ad91812b 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -106,17 +106,21 @@ chatProfileTests = do it "should plan and connect via one-time invitation" testPlanShortLinkInvitation it "should connect via contact address" testShortLinkContactAddress it "should join group" testShortLinkJoinGroup - describe "connection via prepared entity" $ do - it "prepare contact using invitation short link data and connect" testShortLinkInvitationPrepareContact - it "prepare contact using address short link data and connect" testShortLinkAddressPrepareContact - it "prepare group using group short link data and connect" testShortLinkPrepareGroup - it "prepare group using group short link data and connect, host rejects" testShortLinkPrepareGroupReject - -- TODO [short links] enable tests - AGENT A_MESSAGE error - xit "setting incognito for invitation should update short link data" testShortLinkInvitationSetIncognito - xit "changing user for invitation should update short link data" testShortLinkInvitationChangeUser - it "changing profile should update address short link data" testShortLinkAddressChangeProfile - it "changing auto-reply message should update address short link data" testShortLinkAddressChangeAutoReply - it "changing group profile should update short link data" testShortLinkGroupChangeProfile + describe "short links with attached data" $ do + it "prepare contact using invitation short link data and connect" testShortLinkInvitationPrepareContact + it "prepare contact using address short link data and connect" testShortLinkAddressPrepareContact + it "prepare group using group short link data and connect" testShortLinkPrepareGroup + it "prepare group using group short link data and connect, host rejects" testShortLinkPrepareGroupReject + it "change prepared contact user" testShortLinkChangePreparedContactUser + it "change prepared contact user, new user has contact with the same name" testShortLinkChangePreparedContactUserDuplicate + it "change prepared group user" testShortLinkChangePreparedGroupUser + it "change prepared group user, new user has group with the same name" testShortLinkChangePreparedGroupUserDuplicate + -- TODO [short links] enable tests - AGENT A_MESSAGE error + xit "setting incognito for invitation should update short link data" testShortLinkInvitationSetIncognito + xit "changing user for invitation should update short link data" testShortLinkInvitationChangeUser + it "changing profile should update address short link data" testShortLinkAddressChangeProfile + it "changing auto-reply message should update address short link data" testShortLinkAddressChangeAutoReply + it "changing group profile should update short link data" testShortLinkGroupChangeProfile testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = @@ -2894,6 +2898,279 @@ testShortLinkPrepareGroupReject = where cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Left GRRBlockedName)}} +testShortLinkChangePreparedContactUser :: HasCallStack => TestParams -> IO () +testShortLinkChangePreparedContactUser = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/create user robert" + showActiveUser bob "robert" + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + + alice ##> "/_connect 1 short=on" + (shortLink, fullLink) <- getShortInvitation alice + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "invitation link: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) + bob <## "alice: contact is prepared" + + -- 2 ids are for "user contacts", 2 ids are for second user contact cards, so alice is id 5 + bob ##> "/_set contact user @5 2" + bob <## "contact alice changed from user bob to user robert" + + bob ##> "/user robert" + showActiveUser bob "robert" + + bob ##> "/_connect contact @5 text hello" + bob + <### [ "alice: connection started", + WithTime "@alice hello" + ] + alice <# "robert> hello" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "robert: contact is connected") + + alice <##> bob + + alice @@@ [("@robert", "hey")] + alice `hasContactProfiles` ["alice", "robert"] + bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "SimpleX Chat team", "SimpleX-Status"] + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + bob @@@ [] + bob `hasContactProfiles` ["bob"] + +testShortLinkChangePreparedContactUserDuplicate :: HasCallStack => TestParams -> IO () +testShortLinkChangePreparedContactUserDuplicate = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/create user robert" + showActiveUser bob "robert" + + connectUsers alice bob + alice <##> bob + + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + + alice ##> "/_connect 1 short=on" + (shortLink, fullLink) <- getShortInvitation alice + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "invitation link: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) + bob <## "alice: contact is prepared" + + -- 2 ids are for "user contacts" + -- 2 ids are for second user contact cards + -- 1 for second user's alice + -- so this alice is id 6 + bob ##> "/_set contact user @6 2" + bob <## "contact alice changed from user bob to user robert, new local name: alice_1" + + bob ##> "/user robert" + showActiveUser bob "robert" + + bob ##> "/_connect contact @6 text hello" + bob + <### [ "alice_1: connection started", + WithTime "@alice_1 hello" + ] + alice <# "robert_1> hello" + concurrently_ + (bob <## "alice_1 (Alice): contact is connected") + (alice <## "robert_1: contact is connected") + + alice #> "@robert_1 hi" + bob <# "alice_1> hi" + bob #> "@alice_1 hey" + alice <# "robert_1> hey" + + alice <##> bob + + alice @@@ [("@robert", "hey"), ("@robert_1", "hey")] + alice `hasContactProfiles` ["alice", "robert", "robert"] + bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@alice_1", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "alice", "SimpleX Chat team", "SimpleX-Status"] + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + bob @@@ [] + bob `hasContactProfiles` ["bob"] + +testShortLinkChangePreparedGroupUser :: HasCallStack => TestParams -> IO () +testShortLinkChangePreparedGroupUser = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice cath + alice ##> "/create link #team short" + (shortLink, fullLink) <- getShortGroupLink alice "team" GRMember True + + bob ##> "/create user robert" + showActiveUser bob "robert" + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect" + groupSLinkData <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) + bob <## "#team: group is prepared" + + bob ##> "/_set group user #1 2" + bob <## "group #team changed from user bob to user robert" + + bob ##> "/user robert" + showActiveUser bob "robert" + + bob ##> "/_connect group #1" + bob <## "#team: connection started" + alice <## "robert: accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: robert joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + bob <## "#team: member cath (Catherine) is connected", + do + cath <## "#team: alice added robert to the group (connecting...)" + cath <## "#team: new member robert is connected" + ] + + alice #> "#team 1" + [bob, cath] *<# "#team alice> 1" + bob #> "#team 2" + [alice, cath] *<# "#team robert> 2" + threadDelay 1000000 + cath #> "#team 3" + [alice, bob] *<# "#team cath> 3" + + alice @@@ [("#team", "3"), ("@cath","sent invitation to join group team as admin")] + alice `hasContactProfiles` ["alice", "cath", "robert"] + bob #$> ("/_get chats 2 pcc=on", chats, [("#team", "3"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "cath", "SimpleX Chat team", "SimpleX-Status"] + cath @@@ [("#team", "3"), ("@alice","received invitation to join group team as admin")] + cath `hasContactProfiles` ["cath", "alice", "robert"] + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + bob @@@ [] + bob `hasContactProfiles` ["bob"] + +testShortLinkChangePreparedGroupUserDuplicate :: HasCallStack => TestParams -> IO () +testShortLinkChangePreparedGroupUserDuplicate = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice cath + alice ##> "/create link #team short" + (shortLink, fullLink) <- getShortGroupLink alice "team" GRMember True + + bob ##> "/create user robert" + showActiveUser bob "robert" + + bob ##> ("/_connect plan 2 " <> shortLink) + bob <## "group link: ok to connect" + groupSLinkData1 <- getTermLine bob + bob ##> ("/_prepare group 2 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData1) + bob <## "#team: group is prepared" + + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect" + groupSLinkData2 <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData2) + bob <## "#team: group is prepared" + + bob ##> "/_set group user #2 2" + bob <## "group #team changed from user bob to user robert, new local name: #team_1" + + bob ##> "/user robert" + showActiveUser bob "robert" + + bob ##> "/_connect group #2" + bob <## "#team_1: connection started" + alice <## "robert: accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: robert joined the group", + do + bob <## "#team_1: joining the group..." + bob <## "#team_1: you joined the group" + bob <## "#team_1: member cath (Catherine) is connected", + do + cath <## "#team: alice added robert to the group (connecting...)" + cath <## "#team: new member robert is connected" + ] + + alice #> "#team 1" + bob <# "#team_1 alice> 1" + cath <# "#team alice> 1" + + bob #> "#team_1 2" + [alice, cath] *<# "#team robert> 2" + + cath #> "#team 3" + alice <# "#team cath> 3" + bob <# "#team_1 cath> 3" + + -- also connect to the first prepared instance of group + bob ##> "/_connect group #1" + bob <## "#team: connection started" + alice <## "robert_1: accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: robert_1 joined the group", + bob + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member cath_1 (Catherine) is connected", + "#team: member robert_2 is connected", + WithTime "#team alice_1> 1 [>>]", + WithTime "#team robert_2> 2 [>>]", + WithTime "#team cath_1> 3 [>>]", + -- for previously joined instance of group: + "#team_1: alice added robert_1 to the group (connecting...)", + "#team_1: new member robert_1 is connected" + ], + do + cath <## "#team: alice added robert_1 to the group (connecting...)" + cath <## "#team: new member robert_1 is connected" + ] + + alice #> "#team 4" + bob + <### [ WithTime "#team_1 alice> 4", + WithTime "#team alice_1> 4" + ] + cath <# "#team alice> 4" + + bob #> "#team_1 5" + [alice, cath] *<# "#team robert> 5" + bob <# "#team robert_2> 5" + + bob #> "#team 6" + [alice, cath] *<# "#team robert_1> 6" + bob <# "#team_1 robert_1> 6" + + threadDelay 1000000 + cath #> "#team 7" + alice <# "#team cath> 7" + bob + <### [ WithTime "#team_1 cath> 7", + WithTime "#team cath_1> 7" + ] + + alice @@@ [("#team", "7"), ("@cath","sent invitation to join group team as admin")] + alice `hasContactProfiles` ["alice", "cath", "robert", "robert"] + bob `hasContactProfiles` ["robert", "robert", "robert", "alice", "alice", "cath", "cath", "SimpleX Chat team", "SimpleX-Status"] + cath @@@ [("#team", "7"), ("@alice","received invitation to join group team as admin")] + cath `hasContactProfiles` ["cath", "alice", "robert", "robert"] + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + bob @@@ [] + bob `hasContactProfiles` ["bob"] + testShortLinkInvitationSetIncognito :: HasCallStack => TestParams -> IO () testShortLinkInvitationSetIncognito = testChat2 aliceProfile bobProfile $