core: change user for prepared contact or group (#5985)

This commit is contained in:
spaced4ndy
2025-06-13 14:38:17 +00:00
committed by GitHub
parent 29e06d7878
commit b0ee13628b
15 changed files with 828 additions and 38 deletions
+12
View File
@@ -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))
+9 -2
View File
@@ -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[..<i] + newChats[(i + 1)...])
chats = [chatToKeep] + remainingNewChats.map { Chat($0) }
} else {
chats = newChats.map { Chat($0) }
}
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
popChatCollector.clear()
}
+16 -5
View File
@@ -889,7 +889,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> 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)
@@ -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()
@@ -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<User>(\.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
)
}
@@ -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<User>(\.activeOrder, order: .reverse))
if incognitoFirst {
incognitoOption
profilerPickerUserOption(selectedProfile)
@@ -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 = "<group>"; };
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextContactRequestActionsView.swift; sourceTree = "<group>"; };
64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextProfilePickerView.swift; sourceTree = "<group>"; };
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
@@ -1087,6 +1089,7 @@
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */,
64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */,
64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */,
64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */,
);
path = ComposeMessage;
sourceTree = "<group>";
@@ -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 */,
+1 -1
View File
@@ -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) }
+4 -2
View File
@@ -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}
+17 -9
View File
@@ -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),
+30
View File
@@ -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
+65
View File
@@ -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
@@ -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 = ?
+24
View File
@@ -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
+288 -11
View File
@@ -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 $