From 56c8d8696ef4ef08e83e8ca243a33b8405907ce4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:16:04 +0000 Subject: [PATCH] core: prepare and connect to group (#5964) --- apps/ios/Shared/Model/AppAPITypes.swift | 3 + apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 8 +- .../Chat/ComposeMessage/ComposeView.swift | 48 ++++-- .../Views/Chat/Group/GroupChatInfoView.swift | 52 +++---- .../Views/ChatList/ChatPreviewView.swift | 16 +- .../Shared/Views/NewChat/NewChatView.swift | 64 +++++++- apps/ios/SimpleXChat/APITypes.swift | 2 + apps/ios/SimpleXChat/ChatTypes.swift | 15 +- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 80 +++++----- src/Simplex/Chat/Library/Subscriber.hs | 17 ++- src/Simplex/Chat/Store/Connections.hs | 3 +- src/Simplex/Chat/Store/Direct.hs | 10 +- src/Simplex/Chat/Store/Groups.hs | 139 +++++++++++++++--- .../Migrations/M20250526_short_links.hs | 4 + .../SQLite/Migrations/chat_query_plans.txt | 73 ++++++++- .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/Shared.hs | 10 +- src/Simplex/Chat/Types.hs | 1 + src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Profiles.hs | 67 ++++++++- 22 files changed, 486 insertions(+), 133 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index ce44f25d7a..0b05c14447 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -746,6 +746,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection) case startedConnectionToContact(user: UserRef, contact: Contact) + case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) @@ -788,6 +789,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .sentConfirmation: "sentConfirmation" case .sentInvitation: "sentInvitation" case .startedConnectionToContact: "startedConnectionToContact" + case .startedConnectionToGroup: "startedConnectionToGroup" case .sentInvitationToContact: "sentInvitationToContact" case .contactAlreadyExists: "contactAlreadyExists" case .contactDeleted: "contactDeleted" @@ -866,6 +868,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { 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)) + case let .startedConnectionToGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ad15c135ec..3488d65067 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1025,7 +1025,7 @@ func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgConten func apiConnectPreparedGroup(groupId: Int64, incognito: Bool) async throws -> GroupInfo { let r: ChatResponse1 = try await chatSendCmd(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito)) - // if case let .startedConnectionToGroup(_, groupInfo) = r { return groupInfo } // TODO [short links] startedConnectionToGroup response + if case let .startedConnectionToGroup(_, groupInfo) = r { return groupInfo } throw r.unexpected } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index d8facb3820..cb32c29302 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -117,6 +117,10 @@ struct ChatView: View { connectingText() if selectedChatItems == nil { let reason = chat.chatInfo.userCantSendReason + let composeEnabled = ( + chat.chatInfo.sendMsgEnabled || + (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) // allow to join prepared group without message + ) ComposeView( chat: chat, im: im, @@ -126,8 +130,8 @@ struct ChatView: View { selectedRange: $selectedRange, disabledText: reason?.composeLabel ) - .disabled(!cInfo.sendMsgEnabled) - .if(!cInfo.sendMsgEnabled) { v in + .disabled(!composeEnabled) + .if(!composeEnabled) { v in v.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( title: "You can't send messages!", diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 90cb671367..3b01e9ffad 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -389,14 +389,14 @@ struct ComposeView: View { default: previewView() } HStack (alignment: .bottom) { - if !(chat.chatInfo.contact?.sendMsgToConnect ?? false) { + if !chat.chatInfo.nextConnect { attachmentButton() } sendMessageView(simplexLinkProhibited, fileProhibited, voiceProhibited) - if chat.chatInfo.contact?.sendMsgToConnect ?? false { - sendToConnectButton() + if chat.chatInfo.nextConnect { + nextConnectButton() .padding(.bottom, 16) .padding(.horizontal, 8) } @@ -581,7 +581,7 @@ struct ComposeView: View { composeState.liveMessage = nil chatModel.removeLiveDummy() }, - showComposeActionButtons: !(chat.chatInfo.contact?.sendMsgToConnect ?? false), + showComposeActionButtons: !chat.chatInfo.nextConnect, voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited, showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, @@ -636,23 +636,37 @@ struct ComposeView: View { } } - private func sendToConnectButton() -> some View { + @ViewBuilder private func nextConnectButton() -> some View { + let connectButtonEnabled = ( + composeState.sendEnabled || + (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) // allow to join prepared group without message + ) Button { Task { if chat.chatInfo.contact?.nextSendGrpInv ?? false { await sendMemberContactInvitation() } else if chat.chatInfo.contact?.nextConnectPrepared ?? false { await sendConnectPreparedContact() + } else if chat.chatInfo.groupInfo?.nextConnectPrepared ?? false { + await connectPreparedGroup() } } } label: { - HStack { - Text("Connect") - .fontWeight(.medium) - Image(systemName: "person.fill.badge.plus") + if case .group = chat.chatInfo { + HStack { + Text("Join") + .fontWeight(.medium) + Image(systemName: "person.2.fill") + } + } else { + HStack { + Text("Connect") + .fontWeight(.medium) + Image(systemName: "person.fill.badge.plus") + } } } - .disabled(!composeState.sendEnabled) + .disabled(!connectButtonEnabled) } private func sendMemberContactInvitation() async { @@ -684,6 +698,20 @@ 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) + await MainActor.run { + self.chatModel.updateGroup(groupInfo) + clearState() + } + } catch { + logger.error("ChatView.connectPreparedGroup error: \(error.localizedDescription)") + AlertManager.shared.showAlertMsg(title: "Error joining group", message: "Error: \(responseError(error))") + } + } + private func checkLinkPreview() -> MsgContent { let msgText = composeState.message switch (composeState.preview) { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 55d7b626fa..e2ff8c2511 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -140,36 +140,38 @@ struct GroupChatInfoView: View { } footer: { Text("Delete chat messages from your device.") } - - Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - Label("Invite members", systemImage: "plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { alert = .cantInviteIncognitoAlert } - } else { - addMembersButton() + + if !groupInfo.nextConnectPrepared { + Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + Label("Invite members", systemImage: "plus") + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .onTapGesture { alert = .cantInviteIncognitoAlert } + } else { + addMembersButton() + } } - } - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) - let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembers = s == "" ? members : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } - MemberRowView( - chat: chat, - groupInfo: groupInfo, - groupMember: GMember(groupInfo.membership), - scrollToItemId: $scrollToItemId, - user: true, - alert: $alert - ) - ForEach(filteredMembers) { member in - MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert) + MemberRowView( + chat: chat, + groupInfo: groupInfo, + groupMember: GMember(groupInfo.membership), + scrollToItemId: $scrollToItemId, + user: true, + alert: $alert + ) + ForEach(filteredMembers) { member in + MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert) + } } } - + Section { clearChatButton() if groupInfo.canDelete { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 2f5eabb8c9..6b2ac12c31 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -340,12 +340,16 @@ struct ChatPreviewView: View { chatPreviewInfoText("connecting…") } case let .group(groupInfo, _): - switch (groupInfo.membership.memberStatus) { - case .memRejected: chatPreviewInfoText("rejected") - case .memInvited: groupInvitationPreviewText(groupInfo) - case .memAccepted: chatPreviewInfoText("connecting…") - case .memPendingReview, .memPendingApproval: chatPreviewInfoText("reviewed by admins") - default: EmptyView() + if groupInfo.nextConnectPrepared { + chatPreviewInfoText("open to join") + } else { + switch (groupInfo.membership.memberStatus) { + case .memRejected: chatPreviewInfoText("rejected") + case .memInvited: groupInvitationPreviewText(groupInfo) + case .memAccepted: chatPreviewInfoText("connecting…") + case .memPendingReview, .memPendingApproval: chatPreviewInfoText("reviewed by admins") + default: EmptyView() + } } default: EmptyView() } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index c36ba14c76..e6abdb41f8 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1025,9 +1025,49 @@ private func showPrepareContactAlert( await MainActor.run { ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact))) openKnownContact(contact, dismiss: dismiss, showAlreadyExistsAlert: nil) + cleanup?() } } catch let error { - logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)") + logger.error("showPrepareContactAlert apiPrepareContact error: \(error.localizedDescription)") + showAlert(NSLocalizedString("Error preparing contact", comment: ""), message: responseError(error)) + await MainActor.run { + cleanup?() + } + } + } + } + ) +} + +private func showPrepareGroupAlert( + connectionLink: CreatedConnLink, + groupShortLinkData: GroupShortLinkData, + theme: AppTheme, + dismiss: Bool, + cleanup: (() -> Void)? +) { + showOpenChatAlert( + profileName: groupShortLinkData.groupProfile.displayName, + profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: 60), + theme: theme, + cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), + confirmTitle: NSLocalizedString("Open chat", comment: "new chat action"), + onCancel: { cleanup?() }, + onConfirm: { + Task { + do { + let groupInfo = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData) + await MainActor.run { + ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil))) + openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) + cleanup?() + } + } catch let error { + logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)") + showAlert(NSLocalizedString("Error preparing group", comment: ""), message: responseError(error)) + await MainActor.run { + cleanup?() + } } } } @@ -1050,7 +1090,7 @@ func planAndConnect( switch ilp { case let .ok(contactSLinkData_): if let contactSLinkData = contactSLinkData_ { - logger.debug("planAndConnect, .invitationLink, .ok, no short link data") + logger.debug("planAndConnect, .invitationLink, .ok, short link data present") await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, @@ -1061,7 +1101,7 @@ func planAndConnect( ) } } else { - logger.debug("planAndConnect, .invitationLink, .ok, short link data present") + logger.debug("planAndConnect, .invitationLink, .ok, no short link data") await MainActor.run { showAskCurrentOrIncognitoProfileSheet( title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"), @@ -1111,7 +1151,7 @@ func planAndConnect( switch cap { case let .ok(contactSLinkData_): if let contactSLinkData = contactSLinkData_ { - logger.debug("planAndConnect, .contactAddress, .ok, no short link data") + logger.debug("planAndConnect, .contactAddress, .ok, short link data present") await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, @@ -1122,7 +1162,7 @@ func planAndConnect( ) } } else { - logger.debug("planAndConnect, .contactAddress, .ok, short link data present") + logger.debug("planAndConnect, .contactAddress, .ok, no short link data") await MainActor.run { showAskCurrentOrIncognitoProfileSheet( title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"), @@ -1189,10 +1229,18 @@ func planAndConnect( switch glp { case let .ok(groupSLinkData_): if let groupSLinkData = groupSLinkData_ { - logger.debug("planAndConnect, .groupLink, .ok, no short link data") - // TODO [short links] showPrepareGroupAlert -> apiPrepareGroup - } else { logger.debug("planAndConnect, .groupLink, .ok, short link data present") + await MainActor.run { + showPrepareGroupAlert( + connectionLink: connectionLink, + groupShortLinkData: groupSLinkData, + theme: theme, + dismiss: dismiss, + cleanup: cleanup + ) + } + } else { + logger.debug("planAndConnect, .groupLink, .ok, no short link data") await MainActor.run { showAskCurrentOrIncognitoProfileSheet( title: NSLocalizedString("Join group", comment: "new chat sheet title"), diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b8d2361ac8..3d36481e85 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -685,6 +685,7 @@ public enum ChatErrorType: Decodable, Hashable { case invalidConnReq case unsupportedConnReq case invalidChatMessage(connection: Connection, message: String) + case connReqMessageProhibited case contactNotReady(contact: Contact) case contactNotActive(contact: Contact) case contactDisabled(contact: Contact) @@ -761,6 +762,7 @@ public enum StoreError: Decodable, Hashable { case groupNotFoundByName(groupName: GroupName) case groupMemberNameNotFound(groupId: Int64, groupMemberName: ContactName) case groupMemberNotFound(groupMemberId: Int64) + case groupHostMemberNotFound(groupId: Int64) case groupMemberNotFoundByMemberId(memberId: String) case memberContactGroupMemberNotFound(contactId: Int64) case groupWithoutUser diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 889469511a..87e29d1d1a 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1326,7 +1326,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } } - + public var chatDeleted: Bool { get { switch self { @@ -1336,6 +1336,16 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var nextConnect: Bool { + get { + switch self { + case let .direct(contact): return contact.sendMsgToConnect + case let .group(groupInfo, _): return groupInfo.nextConnectPrepared + default: return false + } + } + } + public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? { get { switch self { @@ -2056,12 +2066,14 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { var updatedAt: Date var chatTs: Date? public var connLinkToConnect: CreatedConnLink? + public var connLinkStartedConnection: Bool public var uiThemes: ThemeModeOverrides? public var membersRequireAttention: Int public var id: ChatId { get { "#\(groupId)" } } public var apiId: Int64 { get { groupId } } public var ready: Bool { get { true } } + public var nextConnectPrepared: Bool { get { connLinkToConnect != nil && !connLinkStartedConnection } } public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } @@ -2094,6 +2106,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, + connLinkStartedConnection: false, membersRequireAttention: 0, chatTags: [], localAlias: "" diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c28053a8ca..e0a7d88822 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -688,6 +688,7 @@ data ChatResponse | CRSentConfirmation {user :: User, connection :: PendingContactConnection} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRStartedConnectionToContact {user :: User, contact :: Contact} + | CRStartedConnectionToGroup {user :: User, groupInfo :: GroupInfo} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} | CRItemsReadForChat {user :: User, chatInfo :: AChatInfo} | CRContactDeleted {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ff154cb4cf..88200e9463 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1740,17 +1740,15 @@ processChatCommand' vr = \case pure conn' APIConnectPlan userId cLink -> withUserId userId $ \user -> uncurry (CRConnectionPlan user) <$> connectPlan user cLink - APIPrepareContact userId link contactSLinkData -> withUserId userId $ \user -> do + APIPrepareContact userId accLink contactSLinkData -> withUserId userId $ \user -> do let ContactShortLinkData {profile, welcomeMsg} = contactSLinkData - ct <- withStore $ \db -> createPreparedContact db user profile link + ct <- withStore $ \db -> createPreparedContact db user profile accLink forM_ welcomeMsg $ \msg -> createInternalChatItem user (CDDirectRcv ct) (CIRcvMsgContent $ MCText msg) Nothing pure $ CRNewPreparedContact user ct - APIPrepareGroup userId link groupSLinkData -> withUserId userId $ \user -> do + APIPrepareGroup userId accLink groupSLinkData -> withUserId userId $ \user -> do let GroupShortLinkData {groupProfile} = groupSLinkData - -- TODO [short link] create host member for group connection on CONF, XGrpLinkInv (as in createGroupViaLink') - -- TODO - see other problems in createPreparedGroup: invited member id (user member), business chats - gInfo <- withStore $ \db -> createPreparedGroup db vr user groupProfile link + 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 @@ -1759,42 +1757,44 @@ processChatCommand' vr = \case ok_ APIChangeGroupUser groupId newUserId -> withUser $ \user -> do ok_ - -- Alternative to passing incognito to APIConnectPreparedContact, APIConnectPreparedGroup would be to - -- create new APIs to set incognito on entity - APISetContactIncognito, APISetGroupIncognito. - -- It would be more complex: - -- - would require to persist incognito profile on entity opposing to connection as currently, - -- - would require decomposing part of APIConnect. - -- As it's an edge case / not a big issue that it's not persisted like a change of user, - -- we're simply passing it to prepare here. - APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user@User {userId} -> do - ct@Contact {connLinkToConnect} <- withFastStore $ \db -> getContact db vr user contactId + APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do + Contact {connLinkToConnect} <- withFastStore $ \db -> getContact db vr user contactId case connLinkToConnect of Nothing -> throwCmdError "contact doesn't have link to connect" - Just link -> case link of - (ACCL SCMInvitation ccLink) -> - connectViaInvitation user incognito ccLink (Just contactId) >>= \case - CRSentConfirmation {} -> do - -- get updated contact with connection - ct' <- withFastStore $ \db -> getContact db vr user contactId - forM_ msgContent_ $ \mc -> do - let evt = XMsgNew $ MCSimple (extMsgContent mc Nothing) - (msg, _) <- sendDirectContactMessage user ct' evt - ci <- saveSndChatItem user (CDDirectSnd ct') msg (CISndMsgContent mc) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] - pure $ CRStartedConnectionToContact user ct' - cr -> pure cr - (ACCL SCMContact ccLink) -> - connectViaContact user incognito ccLink msgContent_ (Just $ CGMContactId contactId) >>= \case - CRSentInvitation {} -> do - -- get updated contact with connection - ct' <- withFastStore $ \db -> getContact db vr user contactId - forM_ msgContent_ $ \mc -> - createInternalChatItem user (CDDirectSnd ct') (CISndMsgContent mc) Nothing - pure $ CRStartedConnectionToContact user ct' - cr -> pure cr - -- TODO [short links] connect to prepared group + Just (ACCL SCMInvitation ccLink) -> + connectViaInvitation user incognito ccLink (Just contactId) >>= \case + CRSentConfirmation {} -> do + -- get updated contact with connection + ct' <- withFastStore $ \db -> getContact db vr user contactId + forM_ msgContent_ $ \mc -> do + let evt = XMsgNew $ MCSimple (extMsgContent mc Nothing) + (msg, _) <- sendDirectContactMessage user ct' evt + ci <- saveSndChatItem user (CDDirectSnd ct') msg (CISndMsgContent mc) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci] + pure $ CRStartedConnectionToContact user ct' + cr -> pure cr + Just (ACCL SCMContact ccLink) -> + connectViaContact user incognito ccLink msgContent_ (Just $ CGMContactId contactId) >>= \case + CRSentInvitation {} -> do + -- get updated contact with connection + ct' <- withFastStore $ \db -> getContact db vr user contactId + forM_ msgContent_ $ \mc -> + createInternalChatItem user (CDDirectSnd ct') (CISndMsgContent mc) Nothing + pure $ CRStartedConnectionToContact user ct' + cr -> pure cr APIConnectPreparedGroup groupId incognito -> withUser $ \user -> do - ok_ + (gInfo, hostMember) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getHostMember db vr user groupId + let GroupInfo {connLinkToConnect} = gInfo + case connLinkToConnect of + Nothing -> throwCmdError "group doesn't have link to connect" + Just ccLink -> + connectViaContact user incognito ccLink Nothing (Just $ CGMGroupMemberId (groupMemberId' hostMember)) >>= \case + CRSentInvitation {connection = PendingContactConnection {pccConnId}} -> do + gInfo' <- withStore' $ \db -> do + setViaGroupLinkHash db groupId pccConnId + setGroupConnLinkStartedConnection db gInfo True + pure $ CRStartedConnectionToGroup user gInfo' + cr -> pure cr APIConnect userId incognito (Just (ACCL SCMInvitation ccLink)) mc_ -> withUserId userId $ \user -> do when (isJust mc_) $ throwChatError CEConnReqMessageProhibited connectViaInvitation user incognito ccLink Nothing @@ -2910,8 +2910,6 @@ processChatCommand' vr = \case ( CRInvitationUri crData {crScheme = SSSimplex} e2e, CRInvitationUri crData {crScheme = simplexChat} e2e ) - -- TODO [short links] Maybe Int64 should be Maybe to differentiate between contact and group links; - -- TODO link connection to entity in createConnReqConnection connectViaContact :: User -> IncognitoEnabled -> CreatedLinkContact -> Maybe MsgContent -> Maybe ContactOrGroupMemberId -> CM ChatResponse connectViaContact user@User {userId} incognito (CCLink cReq@(CRContactUri ConnReqUriData {crClientData}) sLnk) mc_ comId_ = withInvitationLock "connectViaContact" (strEncode cReq) $ do let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9d8816d5ac..2df5ef8721 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -677,7 +677,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () - processGroupMessage agentMsg connEntity conn@Connection {connId, connChatVersion, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of + processGroupMessage agentMsg connEntity conn@Connection {connId, connChatVersion, customUserProfileId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of @@ -735,6 +735,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" _ -> messageError "CONF from invited member must have x.grp.acpt" + GCHostMember -> + case chatMsgEvent of + XGrpLinkInv glInv -> do + -- XGrpLinkInv here means we are connecting via prepared group, and we have to update user and host member records + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db vr user gInfo m glInv + -- [incognito] send saved profile + incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) + let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing True + allowAgentConnectionAsync user conn' confId $ XInfo profileToSend + toView $ CEvtGroupLinkConnecting user gInfo' m' + XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersRejected db vr user gInfo m glRjct + toView $ CEvtGroupLinkConnecting user gInfo' m' + toViewTE $ TEGroupLinkRejected user gInfo' rejectionReason + _ -> messageError "CONF from host member in prepared group must have x.grp.link.inv or x.grp.link.reject" _ -> case chatMsgEvent of XGrpMemInfo memId _memProfile diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 15e53cd162..fb6916ad78 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -139,7 +139,8 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 4d5dc92de1..f18f524708 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -169,7 +169,7 @@ createConnReqConnection db userId acId cReqHash sLnk comId_ xContactId incognito created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, pccConnStatus, ConnContact, BI True) + ( (userId, acId, pccConnStatus, connType, BI True) :. (cReqHash, sLnk, contactId_, groupMemberId_) :. (xContactId, customUserProfileId, BI (isJust groupLinkId), groupLinkId) :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) @@ -177,10 +177,10 @@ createConnReqConnection db userId acId cReqHash sLnk comId_ xContactId incognito pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connLinkInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} where - (contactId_, groupMemberId_) = case comId_ of - Just (CGMContactId ctId) -> (Just ctId, Nothing) - Just (CGMGroupMemberId gmId) -> (Nothing, Just gmId) - Nothing -> (Nothing, Nothing) + (connType, contactId_, groupMemberId_) = case comId_ of + Just (CGMContactId ctId) -> (ConnContact, Just ctId, Nothing) + Just (CGMGroupMemberId gmId) -> (ConnMember, Nothing, Just gmId) + Nothing -> (ConnContact, Nothing, Nothing) getConnReqContactXContactId :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId db vr user@User {userId} cReqHash = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 38ab9fa53e..948c24c7a9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -33,6 +33,9 @@ module Simplex.Chat.Store.Groups createGroupInvitation, deleteContactCardKeepConn, createPreparedGroup, + setGroupConnLinkStartedConnection, + updatePreparedUserAndHostMembersInvited, + updatePreparedUserAndHostMembersRejected, createGroupInvitedViaLink, createGroupRejectedViaLink, setViaGroupLinkHash, @@ -50,6 +53,7 @@ module Simplex.Chat.Store.Groups getActiveMembersByName, getGroupInfoByName, getGroupMember, + getHostMember, getMentionedGroupMember, getMentionedMemberByMemberId, getGroupMemberById, @@ -156,6 +160,7 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages import Simplex.Chat.Protocol (MsgMention (..), groupForwardVersion) import Simplex.Chat.Store.Direct @@ -284,7 +289,8 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} @@ -367,6 +373,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, connLinkToConnect = Nothing, + connLinkStartedConnection = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, @@ -439,6 +446,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, connLinkToConnect = Nothing, + connLinkStartedConnection = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, @@ -537,22 +545,102 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) createPreparedGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> ACreatedConnLink -> ExceptT StoreError IO GroupInfo -createPreparedGroup db vr user@User {userId} groupProfile connLinkToConnect = do +createPreparedGroup db vr user@User {userId, userContactId} groupProfile connLinkToConnect = do currentTs <- liftIO getCurrentTime - -- TODO [short links] support preparing business chats - let business = Nothing - groupId <- createGroup_ db userId groupProfile (Just connLinkToConnect) business currentTs - -- TODO [short links] create "unknown" host member here? set invitedByGroupMemberId later? - -- TODO - same for invitedMember - -- TODO - for membershipStatus - new status GSMemNew? - -- TODO - customUserProfileId - pass on APIConnectPreparedGroup, update member; or separate apis for switching before joining? - let invitedByGroupMemberId = Nothing - invitedMember = MemberIdRole (MemberId "unknown") GRMember - membershipStatus = GSMemAccepted - customUserProfileId = Nothing - void $ createContactMemberInv_ db user groupId invitedByGroupMemberId user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId currentTs vr - -- TODO [short links] review: setViaGroupLinkHash + (groupId, groupLDN) <- createGroup_ db userId groupProfile (Just connLinkToConnect) Nothing currentTs + hostMemberId <- insertHost_ currentTs groupId groupLDN + let userMember = MemberIdRole (MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id") GRMember + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing currentTs vr getGroupInfo db vr user groupId + where + insertHost_ currentTs groupId groupLDN = do + let memberId = MemberId $ encodeUtf8 groupLDN <> "_host_unknown_id" + hostProfile = profileFromName $ nameFromMemberId memberId + (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + ) + insertedRowId db + +setGroupConnLinkStartedConnection :: DB.Connection -> GroupInfo -> Bool -> IO GroupInfo +setGroupConnLinkStartedConnection db groupInfo@GroupInfo {groupId} connLinkStartedConnection = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET conn_link_started_connection = ?, updated_at = ? WHERE group_id = ?" + (BI connLinkStartedConnection, currentTs, groupId) + pure groupInfo {connLinkStartedConnection = connLinkStartedConnection} + +updatePreparedUserAndHostMembersInvited :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembersInvited db vr user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted} = do + let fromMemberProfile = profileFromName fromMemberName + initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted + updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile initialStatus + +updatePreparedUserAndHostMembersRejected :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembersRejected db vr user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do + let fromMemberProfile = profileFromName $ nameFromMemberId memberId + updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile GSMemRejected + +updatePreparedUserAndHostMembers' :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembers' + db + vr + user + gInfo@GroupInfo {groupId, groupProfile = gp} + hostMember + fromMember + fromMemberProfile + invitedMember + groupProfile + membershipStatus = do + currentTs <- liftIO getCurrentTime + liftIO $ updateUserMember currentTs + hostMember' <- updateHostMember currentTs + when (gp /= groupProfile) $ + void $ updateGroupProfile db user gInfo groupProfile + gInfo' <- getGroupInfo db vr user groupId + pure (gInfo', hostMember') + where + updateUserMember currentTs = do + let GroupInfo {membership} = gInfo + MemberIdRole memberId memberRole = invitedMember + DB.execute + db + [sql| + UPDATE group_members + SET member_id = ?, + member_role = ?, + member_status = ?, + updated_at = ? + WHERE group_member_id = ? + |] + (memberId, memberRole, membershipStatus, currentTs, groupMemberId' membership) + updateHostMember currentTs = do + _ <- updateMemberProfile db user hostMember fromMemberProfile + let MemberIdRole memberId memberRole = fromMember + gmId = groupMemberId' hostMember + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET member_id = ?, + member_role = ?, + updated_at = ? + WHERE group_member_id = ? + |] + (memberId, memberRole, currentTs, gmId) + getGroupMemberById db vr user gmId createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do @@ -578,7 +666,7 @@ createGroupViaLink' business membershipStatus = do currentTs <- liftIO getCurrentTime - groupId <- createGroup_ db userId groupProfile Nothing business currentTs + (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business currentTs hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact @@ -603,7 +691,7 @@ createGroupViaLink' ) insertedRowId db -createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe ACreatedConnLink -> Maybe BusinessChatInfo -> UTCTime -> ExceptT StoreError IO GroupId +createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe ACreatedConnLink -> Maybe BusinessChatInfo -> UTCTime -> ExceptT StoreError IO (GroupId, Text) createGroup_ db userId groupProfile connLinkToConnect business currentTs = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do @@ -623,7 +711,8 @@ createGroup_ db userId groupProfile connLinkToConnect business currentTs = Excep VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. connLinkToConnectRow connLinkToConnect :. businessChatInfoRow business) - insertedRowId db + groupId <- insertedRowId db + pure (groupId, localDisplayName) setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () setViaGroupLinkHash db groupId connId = @@ -800,7 +889,8 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, @@ -893,6 +983,14 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) +getHostMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupMember +getHostMember db vr user@User {userId} groupId = + ExceptT . firstRow (toContactMember vr user) (SEGroupHostMemberNotFound groupId) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") + (userId, groupId, GCHostMember) + getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention getMentionedGroupMember db User {userId} groupId gmId = ExceptT $ @@ -1660,7 +1758,8 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs index 75f24bfae3..9fd30a1a73 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs @@ -13,8 +13,10 @@ m20250526_short_links = [sql| ALTER TABLE contacts ADD COLUMN conn_full_link_to_connect BLOB; ALTER TABLE contacts ADD COLUMN conn_short_link_to_connect BLOB; + ALTER TABLE groups ADD COLUMN conn_full_link_to_connect BLOB; ALTER TABLE groups ADD COLUMN conn_short_link_to_connect BLOB; +ALTER TABLE groups ADD COLUMN conn_link_started_connection INTEGER NOT NULL DEFAULT 0; |] down_m20250526_short_links :: Query @@ -22,6 +24,8 @@ down_m20250526_short_links = [sql| ALTER TABLE contacts DROP COLUMN conn_full_link_to_connect; ALTER TABLE contacts DROP COLUMN conn_short_link_to_connect; + ALTER TABLE groups DROP COLUMN conn_full_link_to_connect; ALTER TABLE groups DROP COLUMN conn_short_link_to_connect; +ALTER TABLE groups DROP COLUMN conn_link_started_connection; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index ce760cfffb..7eba73967b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -46,7 +46,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} @@ -277,6 +278,16 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_id = ?, + member_role = ?, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE xftp_file_descriptions SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ? @@ -294,6 +305,14 @@ Query: Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO group_members ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, @@ -656,6 +675,17 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_id = ?, + member_role = ?, + member_status = ?, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? @@ -853,7 +883,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} @@ -902,7 +933,8 @@ Query: SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, @@ -4580,7 +4612,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership @@ -4605,7 +4638,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership @@ -4683,6 +4717,31 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_id = ? AND m.member_category = ? +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -6013,6 +6072,10 @@ Query: UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET conn_link_started_connection = ?, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index dfea7f6909..1925e0bd75 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -139,7 +139,8 @@ CREATE TABLE groups( local_alias TEXT DEFAULT '', members_require_attention INTEGER NOT NULL DEFAULT 0, conn_full_link_to_connect BLOB, - conn_short_link_to_connect BLOB, -- received + conn_short_link_to_connect BLOB, + conn_link_started_connection INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index f777e94f98..0964e360b7 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -89,6 +89,7 @@ data StoreError | SEGroupNotFoundByName {groupName :: GroupName} | SEGroupMemberNameNotFound {groupId :: GroupId, groupMemberName :: ContactName} | SEGroupMemberNotFound {groupMemberId :: GroupMemberId} + | SEGroupHostMemberNotFound {groupId :: GroupId} | SEGroupMemberNotFoundByMemberId {memberId :: MemberId} | SEMemberContactGroupMemberNotFound {contactId :: ContactId} | SEGroupWithoutUser @@ -597,12 +598,12 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime, Maybe ConnReqContact, Maybe ShortLinkContact) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64, Int) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. (Maybe ConnReqContact, Maybe ShortLinkContact, BoolInt) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64, Int) :. GroupMemberRow type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt, connFullLink, connShortLink) :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. (connFullLink, connShortLink, BI connLinkStartedConnection) :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -611,7 +612,7 @@ toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, connLinkToConnect = case (connFullLink, connShortLink) of (Nothing, _) -> Nothing (Just fullLink, shortLink_) -> Just $ CCLink fullLink shortLink_ - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, connLinkToConnect, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, connLinkToConnect, connLinkStartedConnection, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = @@ -644,7 +645,8 @@ groupInfoQuery = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_started_connection, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 8db7e27a39..ee76fec94d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -420,6 +420,7 @@ data GroupInfo = GroupInfo chatTs :: Maybe UTCTime, userMemberProfileSentAt :: Maybe UTCTime, connLinkToConnect :: Maybe CreatedLinkContact, + connLinkStartedConnection :: Bool, chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4989f1ca12..c93d9048cf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -195,6 +195,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRStartedConnectionToContact u c -> ttyUser u [ttyContact' c <> ": connection started"] + CRStartedConnectionToGroup u g -> ttyUser u [ttyGroup' g <> ": connection started"] CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRItemsReadForChat u _chatId -> ttyUser u ["items read for chat"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 0fdae6fc02..2a0ab556c1 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -17,11 +17,11 @@ import Control.Monad.Except import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) import Simplex.Chat.Options import Simplex.Chat.Protocol (currentChatVersion) import Simplex.Chat.Store.Shared (createContact) -import Simplex.Chat.Types (ConnStatus (..), Profile (..)) +import Simplex.Chat.Types (ConnStatus (..), Profile (..), GroupRejectionReason (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite @@ -109,6 +109,8 @@ chatProfileTests = do 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 testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = @@ -2810,3 +2812,64 @@ testShortLinkAddressPrepareContact = (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") alice <##> bob + +testShortLinkPrepareGroup :: HasCallStack => TestParams -> IO () +testShortLinkPrepareGroup = + 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 ##> ("/_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 ##> "/_connect group #1" + bob <## "#team: connection started" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob 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 bob (Bob) to the group (connecting...)" + cath <## "#team: new member bob is connected" + ] + alice #> "#team 1" + [bob, cath] *<# "#team alice> 1" + bob #> "#team 2" + [alice, cath] *<# "#team bob> 2" + cath #> "#team 3" + [alice, bob] *<# "#team cath> 3" + +testShortLinkPrepareGroupReject :: HasCallStack => TestParams -> IO () +testShortLinkPrepareGroupReject = + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice cath + alice ##> "/create link #team short" + (shortLink, fullLink) <- getShortGroupLink alice "team" GRMember True + 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 ##> "/_connect group #1" + bob <## "#team: connection started" + alice <## "bob (Bob): rejecting request to join group #team, reason: GRRBlockedName" + bob <## "#team: joining the group..." + bob <## "#team: join rejected, reason: GRRBlockedName" + + alice #> "#team 1" + cath <# "#team alice> 1" + cath #> "#team 2" + alice <# "#team cath> 2" + + -- rejected member can't send messages to group + bob ##> "#team hello" + bob <## "bad chat command: not current member" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Left GRRBlockedName)}}