diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 26438bc60e..6d22acc232 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1191,6 +1191,7 @@ enum ChatEvent: Decodable, ChatAPIResult { // group events case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) @@ -1272,6 +1273,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .chatItemsDeleted: "chatItemsDeleted" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .receivedGroupInvitation: "receivedGroupInvitation" + case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup" case .userAcceptedGroupSent: "userAcceptedGroupSent" case .groupLinkConnecting: "groupLinkConnecting" case .businessLinkConnecting: "businessLinkConnecting" @@ -1356,6 +1358,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 21335cee64..d04ccf5b03 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2705,7 +2705,8 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) - case let .receivedGroupInvitation(user, groupInfo, _, _): + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index ddb58fdfd1..fecd1ba891 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -39,6 +39,13 @@ struct CIGroupInvitationView: View { Divider().frame(width: frameWidth) + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + if action { VStack(alignment: .leading, spacing: 2) { groupInvitationText() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 5f1603423a..14bb9304d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3128,6 +3128,13 @@ object ChatController { // TODO NtfManager.shared.notifyGroupInvitation } } + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return @@ -6815,6 +6822,7 @@ sealed class CR { @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -7015,6 +7023,7 @@ sealed class CR { is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" is ReceivedGroupInvitation -> "receivedGroupInvitation" + is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" @@ -7206,6 +7215,7 @@ sealed class CR { is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") + is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole") is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 649188502d..14ecd723b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -29,6 +29,7 @@ fun CIGroupInvitationView( memberRole: GroupMemberRole, showTimestamp: Boolean, chatIncognito: Boolean = false, + groupChat: Boolean = false, joinGroup: (Long, () -> Unit) -> Unit, timedMessagesTTL: Int? ) { @@ -111,6 +112,9 @@ fun CIGroupInvitationView( val secondaryColor = MaterialTheme.colors.secondary Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (groupChat) { + Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) + } if (action) { Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index cb3a07b7f9..822aef9aa2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -720,11 +720,11 @@ fun ChatItemView( DeleteItemMenu() } is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) DeleteItemMenu() } is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) DeleteItemMenu() } is CIContent.RcvDirectEventContent -> { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 3a1720d883..93bf4f5838 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1769,6 +1769,8 @@ You joined this group You rejected group invitation Group invitation expired + For you only + For %1$s only deleted contact diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index af7b0146f7..96fe5422cd 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group commands](#group-commands) - [APIAddMember](#apiaddmember) +- [APIAddMemberViaGroup](#apiaddmemberviagroup) - [APIJoinGroup](#apijoingroup) - [APIAcceptMember](#apiacceptmember) - [APIMembersRole](#apimembersrole) @@ -761,6 +762,52 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIAddMemberViaGroup + +Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + +*Network usage*: interactive. + +**Parameters**: +- groupId: int64 +- viaGroupId: int64 +- groupMemberId: int64 +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +**Syntax**: + +``` +/_add # # relay|observer|author|member|moderator|admin|owner +``` + +```javascript +'/_add #' + groupId + ' #' + viaGroupId + ' ' + groupMemberId + ' ' + memberRole // JavaScript +``` + +```python +'/_add #' + str(groupId) + ' #' + str(viaGroupId) + ' ' + str(groupMemberId) + ' ' + str(memberRole) # Python +``` + +**Responses**: + +SentGroupInvitationViaGroup: Group invitation sent to member of another group. +- type: "sentGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- member: [GroupMember](./TYPES.md#groupmember) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +**Errors**: +- PeerChatVRangeIncompatible: Member's client version is older than required. + +--- + + ### APIJoinGroup Join group. diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index 5416fe4c0e..7c43ce3bd4 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group events](#group-events) - Main events - [ReceivedGroupInvitation](#receivedgroupinvitation) + - [ReceivedGroupInvitationViaGroup](#receivedgroupinvitationviagroup) - [UserJoinedGroup](#userjoinedgroup) - [GroupUpdated](#groupupdated) - [JoinedGroupMember](#joinedgroupmember) @@ -286,6 +287,22 @@ Received group invitation. --- +### ReceivedGroupInvitationViaGroup + +Received group invitation from member of another group. + +**Record type**: +- type: "receivedGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) +- fromMemberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +--- + + ### UserJoinedGroup Bot user joined group. Received when connection via group link completes. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 79820605a8..5804b0e945 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -119,6 +119,7 @@ chatCommandsDocsData = ( "Group commands", "Commands to manage and moderate groups. These commands can be used with business chats as well - they are groups. E.g., a common scenario would be to add human agents to business chat with the customer who connected via business address.", [ ("APIAddMember", [], "Add contact to group. Requires bot to have Admin role.", ["CRSentGroupInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_add #" <> Param "groupId" <> " " <> Param "contactId" <> " " <> Param "memberRole"), + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), ("APIJoinGroup", ["enableNtfs"], "Join group.", ["CRUserAcceptedGroupSent", "CRChatCmdError"], [], Just UNInteractive, "/_join #" <> Param "groupId"), ("APIAcceptMember", [], "Accept group member. Requires Admin role.", ["CRMemberAccepted", "CRChatCmdError"], [TD "CEGroupMemberNotActive" "Member is not connected yet"], Just UNBackground, "/_accept member #" <> Param "groupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), ("APIMembersRole", [], "Set members role. Requires Admin role.", ["CRMembersRoleUser", "CRChatCmdError"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"), diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index dab06e9262..48f307f6e4 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -85,6 +85,7 @@ chatEventsDocsData = "Bots may use these events to manage users' groups and business address groups.\n\n\ \*Please note*: programming groups is more complex than programming direct connections", [ ("CEvtReceivedGroupInvitation", ""), + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), ("CEvtUserJoinedGroup", "Bot user joined group. Received when connection via group link completes."), ("CEvtGroupUpdated", "Group profile or preferences updated."), ("CEvtJoinedGroupMember", "Another member joined group."), diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 18392ae1b5..c97e96e302 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -92,6 +92,7 @@ chatResponsesDocsData = ("CRRemoteCtrlConnecting", "Remote controller is connecting."), ("CRSentConfirmation", "Confirmation sent to one-time invitation"), ("CRSentGroupInvitation", "Group invitation sent"), + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), ("CRSentInvitation", "Invitation sent to contact address"), ("CRSentInvitationToContact", "Invitation sent to contact (when connecting via SimpleX name to a known contact address)."), ("CRServiceReplyAccepted", "Service reply accepted for delivery. `connectionId` correlates the reply delivery event."), diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index aa1c889528..8cd655d0c0 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -232,6 +232,8 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.inv` message is sent to invite contact to the group via contact's direct connection and includes group member connection address. This message MUST only be sent by members with `admin` or `owner` role. Optional `groupLinkId` is included when this message is sent to contacts connected via the user's group link. This identifier is a random byte sequence, with no global or even local uniqueness - it is only used for the user's invitations to a given group to provide confirmation to the contact that the group invitation is for the same group the contact was connecting to via the group link, so that the invitation can be automatically accepted by the contact - the contact compares it with the group link id contained in the group link uri's data field. +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. + `x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. It optionally includes the invited member's group profile. The inviting user replies with `x.grp.mem.info` that includes the inviting member's group profile. `x.grp.link.inv` message is sent as part of connection handshake to member joining via group link, and contains group profile and initial information about inviting and joining member. diff --git a/docs/rfcs/2026-10-10-invite-member-via-group.md b/docs/rfcs/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..865c8b08bc --- /dev/null +++ b/docs/rfcs/2026-10-10-invite-member-via-group.md @@ -0,0 +1,204 @@ +# Inviting group members to other groups + +## Problem + +A group admin can invite only a contact to a group. +To invite a member of group A to group B, the admin first creates a contact with this member: + +1. Create a member contact (`APICreateMemberContact`). +2. Send the contact invitation in group A (`x.grp.direct.inv`). +3. Wait until the contact connection is established. +4. Invite the contact to group B (`APIAddMember`, `x.grp.inv`). + +Steps 1-3 are possible only when direct messages are allowed in group A. +A direct chat is created with each invited member. + +## Design + +An admin of groups A and B invites a member of group A to group B. +The invitation is sent as `x.grp.inv` over the connection with this member in group A. +Only the invited member receives the invitation. +The invited member sees it in group A and joins group B in the same way as after an invitation from a contact. + +### Protocol + +`x.grp.inv` is accepted on group member connections, in addition to contact connections. + +``` +event x.grp.inv +params groupInvitation, unchanged +connection connection with the invited member in group A +forwarding excluded +group history excluded +``` + +Sender requirements: + +``` +group A preferences direct messages allowed for the user's role +role in group A author or above +role in group B admin or owner, and not lower than the invited role +group A group, not channel, different from group B +group B group, not channel +membership in A main profile +membership in B main profile +invited member current member with an active connection +invited member version 22 or later +``` + +The receiving client ignores the invitation when: + +``` +group A channel +group B channel +group A preferences direct messages not allowed for the sender's role +sender blocked +fromMember role below admin, or below the invited role +fromMember id equal to the invited member id +``` + +### Sending + +New command, next to `APIAddMember`: + +```haskell +APIAddMemberViaGroup + { groupId :: GroupId, + viaGroupId :: GroupId, + groupMemberId :: GroupMemberId, + memberRole :: GroupMemberRole + } +``` + +``` +groupId group B +viaGroupId group A +groupMemberId member of group A +memberRole role in group B +``` + +Syntax: `/_add # # ` + +For a member outside group A, `SEGroupMemberNotFound` is returned. + +New response, next to `CRSentGroupInvitation`: + +```haskell +CRSentGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + member :: GroupMember, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember + } +``` + +``` +groupInfo group B +member record of the invited member in group B +viaGroupInfo group A +viaMember member of group A +``` + +In group B, the record of the invited member is linked to the member of group A. +Until the member joins, the same invitation is re-sent on a repeated command. +After the member joins, `CEGroupDuplicateMember` is returned. +After the member leaves, is removed or is rejected, a new invitation is sent. + +The sent invitation is saved in group A as `CISndGroupInvitation`, visible to the sender only. +The name of the member in group A is stored in the item (`CIGroupInvitation.localDisplayName`), as the contact name in direct chats. + +### Receiving + +New event, next to `CEvtReceivedGroupInvitation`: + +```haskell +CEvtReceivedGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember, + fromMemberRole :: GroupMemberRole, + memberRole :: GroupMemberRole + } +``` + +``` +groupInfo group B +viaGroupInfo group A +viaMember sender, member of group A +fromMemberRole sender role in group B +memberRole invited role in group B +``` + +``` +group B created with status "invited", or found by the invitation link +chat item CIRcvGroupInvitation in group A, main scope, from the sender +``` + +With automatic acceptance of group invitations enabled, the client joins group B and saves the item with status "accepted". + +With an incognito membership in group A, the same incognito profile is used in group B. + +In group B, copies of the group A profiles are stored for the sender and for the invited member. +Badge proofs bound to group A are dropped from the copies. +The copies are replaced by the profiles sent in the connection handshake (`x.grp.acpt`, `x.grp.mem.info`). + +### Joining + +The invited member joins group B with `APIJoinGroup`, or by tapping the invitation in group A. + +Status of the invitation item: + +``` +pending received +accepted joined +rejected group B deleted before joining +``` + +With disappearing messages on in group A, the item disappears after the status changes, as in direct chats. + +The connection with the host is created from the invitation link, independently of the inviter's connection. +So an invitation from a contact deleted before joining is accepted in the same way. +Before this change, joining failed with `SEContactNotFoundByMemberId`. + +### Clients + +``` +group A existing group invitation card ("Tap to join"), with a label +chat list group B, added on CEvtReceivedGroupInvitationViaGroup +sending API only +``` + +Labels, on group invitation cards in group chats only: + +``` +received card For you only +sent card For only name of the member in group A +``` + +### Bots API + +``` +APIAddMemberViaGroup new command +CRSentGroupInvitationViaGroup new response +CEvtReceivedGroupInvitationViaGroup new event +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +### Version + +``` +chat version 22 +``` + +Version 22 is shared with owner-signed relay invitations, in one constant `groupInvVersion`. +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. + +## Out of scope + +- Role changes of members invited via a group before they join. +- "Open" action on accepted invitations. +- Invitations to members pending approval in group A. +- User interface for sending invitations. diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index cc01ae9472..fc1530f674 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -266,6 +266,23 @@ export namespace APIAddMember { } } +// Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +// Network usage: interactive. +export interface APIAddMemberViaGroup { + groupId: number // int64 + viaGroupId: number // int64 + groupMemberId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APIAddMemberViaGroup { + export type Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + export function cmdString(self: APIAddMemberViaGroup): string { + return '/_add #' + self.groupId + ' #' + self.viaGroupId + ' ' + self.groupMemberId + ' ' + self.memberRole + } +} + // Join group. // Network usage: interactive. export interface APIJoinGroup { diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index 5b644ee41e..b1e8fc6057 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -17,6 +17,7 @@ export type ChatEvent = | CEvt.GroupChatItemsDeleted | CEvt.ChatItemsStatusesUpdated | CEvt.ReceivedGroupInvitation + | CEvt.ReceivedGroupInvitationViaGroup | CEvt.UserJoinedGroup | CEvt.GroupUpdated | CEvt.JoinedGroupMember @@ -73,6 +74,7 @@ export namespace CEvt { | "groupChatItemsDeleted" | "chatItemsStatusesUpdated" | "receivedGroupInvitation" + | "receivedGroupInvitationViaGroup" | "userJoinedGroup" | "groupUpdated" | "joinedGroupMember" @@ -210,6 +212,16 @@ export namespace CEvt { memberRole: T.GroupMemberRole } + export interface ReceivedGroupInvitationViaGroup extends Interface { + type: "receivedGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + fromMemberRole: T.GroupMemberRole + memberRole: T.GroupMemberRole + } + export interface UserJoinedGroup extends Interface { type: "userJoinedGroup" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 647427f0dc..ef83e89e9d 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -51,6 +51,7 @@ export type ChatResponse = | CR.RemoteCtrlConnecting | CR.SentConfirmation | CR.SentGroupInvitation + | CR.SentGroupInvitationViaGroup | CR.SentInvitation | CR.SentInvitationToContact | CR.ServiceReplyAccepted @@ -117,6 +118,7 @@ export namespace CR { | "remoteCtrlConnecting" | "sentConfirmation" | "sentGroupInvitation" + | "sentGroupInvitationViaGroup" | "sentInvitation" | "sentInvitationToContact" | "serviceReplyAccepted" @@ -445,6 +447,15 @@ export namespace CR { member: T.GroupMember } + export interface SentGroupInvitationViaGroup extends Interface { + type: "sentGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + } + export interface SentInvitation extends Interface { type: "sentInvitation" user: T.User diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 10c4ee0a81..67e470e52d 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -261,6 +261,12 @@ export class ChatClient { throw new ChatCommandError("error adding member", r) } + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + async apiJoinGroup(groupId: number): Promise { const r = await this.sendChatCmd(CC.APIJoinGroup.cmdString({groupId})) if (r.type === "userAcceptedGroupSent") return r.groupInfo diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8d4e75b181..8e0c5a545d 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -564,6 +564,16 @@ export class ChatApi { throw new ChatCommandError("error adding member", r) } + /** + * Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + * Network usage: interactive. + */ + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + /** * Join group. * Network usage: interactive. diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index 876230ef39..62dec42292 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -351,6 +351,23 @@ class ChatApi: return r["member"] raise ChatCommandError("error adding member", r) + async def api_add_member_via_group( + self, group_id: int, via_group_id: int, group_member_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAddMemberViaGroup_cmd_string( + { + "groupId": group_id, + "viaGroupId": via_group_id, + "groupMemberId": group_member_id, + "memberRole": member_role, + } + ) + ) + if r["type"] == "sentGroupInvitationViaGroup": + return r["member"] + raise ChatCommandError("error adding member", r) + async def api_join_group(self, group_id: int) -> T.GroupInfo: r = await self.send_chat_cmd(CC.APIJoinGroup_cmd_string({"groupId": group_id})) if r["type"] == "userAcceptedGroupSent": diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 365eff7e10..9dc3a05e8d 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -235,6 +235,21 @@ def APIAddMember_cmd_string(self: APIAddMember) -> str: APIAddMember_Response = CR.SentGroupInvitation | CR.ChatCmdError +# Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +# Network usage: interactive. +class APIAddMemberViaGroup(TypedDict): + groupId: int # int64 + viaGroupId: int # int64 + groupMemberId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAddMemberViaGroup_cmd_string(self: APIAddMemberViaGroup) -> str: + return '/_add #' + str(self['groupId']) + ' #' + str(self['viaGroupId']) + ' ' + str(self['groupMemberId']) + ' ' + str(self['memberRole']) + +APIAddMemberViaGroup_Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + # Join group. # Network usage: interactive. class APIJoinGroup(TypedDict): diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py index bd73637ef8..13f6d5cb6e 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_events.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -84,6 +84,15 @@ class ReceivedGroupInvitation(TypedDict): fromMemberRole: "T.GroupMemberRole" memberRole: "T.GroupMemberRole" +class ReceivedGroupInvitationViaGroup(TypedDict): + type: Literal["receivedGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + fromMemberRole: "T.GroupMemberRole" + memberRole: "T.GroupMemberRole" + class UserJoinedGroup(TypedDict): type: Literal["userJoinedGroup"] user: "T.User" @@ -352,6 +361,7 @@ ChatEvent = ( | GroupChatItemsDeleted | ChatItemsStatusesUpdated | ReceivedGroupInvitation + | ReceivedGroupInvitationViaGroup | UserJoinedGroup | GroupUpdated | JoinedGroupMember @@ -394,7 +404,7 @@ ChatEvent = ( | ChatErrors ) -ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] +ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "receivedGroupInvitationViaGroup", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] class OnEventDecorator(Protocol): @@ -483,6 +493,12 @@ class OnEventDecorator(Protocol): Callable[["ReceivedGroupInvitation"], Awaitable[None]], ]: ... + @overload + def __call__(self, event: Literal["receivedGroupInvitationViaGroup"], /) -> Callable[ + [Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]]], + Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]], + ]: ... + @overload def __call__(self, event: Literal["userJoinedGroup"], /) -> Callable[ [Callable[["UserJoinedGroup"], Awaitable[None]]], diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index e3885fc16a..07c7c04f49 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -264,6 +264,14 @@ class SentGroupInvitation(TypedDict): contact: "T.Contact" member: "T.GroupMember" +class SentGroupInvitationViaGroup(TypedDict): + type: Literal["sentGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + class SentInvitation(TypedDict): type: Literal["sentInvitation"] user: "T.User" @@ -402,6 +410,7 @@ ChatResponse = ( | RemoteCtrlConnecting | SentConfirmation | SentGroupInvitation + | SentGroupInvitationViaGroup | SentInvitation | SentInvitationToContact | ServiceReplyAccepted @@ -420,4 +429,4 @@ ChatResponse = ( | ApiChats ) -ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentGroupInvitationViaGroup", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/plans/2026-10-10-invite-member-via-group.md b/plans/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..ecc9ab06af --- /dev/null +++ b/plans/2026-10-10-invite-member-via-group.md @@ -0,0 +1,612 @@ +# Inviting group members to other groups: implementation plan + +RFC: `docs/rfcs/2026-10-10-invite-member-via-group.md`. + +Branch: `ep/group-inv`. + +Line numbers: `d93fb626b` (`ep/group-inv` after merging `master`), before any change in this plan. + +## 1. Protocol version + +`src/Simplex/Chat/Protocol.hs`. + +Version 22 is shared with owner-signed relay invitations. + +Version history comment, after line 89: + +```haskell +-- 21 - command keywords with any characters (2026-09-27) +-- 22 - owner-signed relay invitations, group invitations via member connections (2026-10-10) +``` + +`currentChatVersion` (line 95): unchanged, `VersionChat 22`. + +`signedRelayInvVersion` (lines 146-147) renamed: + +```haskell +groupInvVersion :: VersionChat +groupInvVersion = VersionChat 22 +``` + +Uses: + +| File | Location | Use | +|---|---|---| +| `src/Simplex/Chat/Library/Subscriber.hs` | line 1657 | `xGrpRelayInv`, renamed reference | +| `src/Simplex/Chat/Library/Commands.hs` | section 5.1 | `APIAddMemberViaGroup` | + +Event types, tags, parsing and encoding: unchanged. + +## 2. Migration + +Files: + +- `src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs` +- `src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs` + +SQLite up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Postgres up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Down, both: + +```sql +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +``` + +Registration: + +| File | Import | List entry | +|---|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | after line 179 | after line 356 | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | after line 56 | after line 110 | +| `simplex-chat.cabal` | after line 171 (Postgres) | after line 349 (SQLite) | + +Regenerated by tests: + +| File | Test | +|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql` | "Schema dump" | +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt` | "Save query plans", last in the full test run | +| `src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql` | "Postgres schema dump", Postgres build; edited by hand | + +## 3. Command, response and event types + +`src/Simplex/Chat/Controller.hs`. + +`ChatCommand`, after line 467: + +```haskell + | APIAddMemberViaGroup {groupId :: GroupId, viaGroupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} +``` + +`ChatResponse`, after line 879: + +```haskell + | CRSentGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, member :: GroupMember, viaGroupInfo :: GroupInfo, viaMember :: GroupMember} +``` + +`ChatEvent`, after line 1030: + +```haskell + | CEvtReceivedGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, viaGroupInfo :: GroupInfo, viaMember :: GroupMember, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +## 4. Store + +`src/Simplex/Chat/Store/Groups.hs`. + +### 4.1. `createGroupInvitation` (line 458) + +```haskell +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Either Contact GroupMember -> VersionRangeChat -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) +createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do +``` + +Equation for `activeConn = Nothing` (line 459): removed. +Peer range: parameter, from the inviter connection. + +| Line | `Left ct` | `Right m` | +|---|---|---| +| 504 | `createContactMemberInv_`, unchanged | host row, see below | +| 505 | membership `invitedBy = IBContact contactId` | membership `invitedBy = IBUnknown` | + +Lines 504-505: + +```haskell + (groupMemberId, invitedBy) <- case inviter of + Left contact@Contact {contactId} -> do + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange + pure (groupMemberId, IBContact contactId) + Right GroupMember {memberProfile} -> do + (hostName, memProfileId, hostProfile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing currentTs + let MemberIdRole {memberId, memberRole} = fromMember + host = + NewGroupMember + { memInfo = MemberInfo memberId memberRole (Just $ ChatVersionRange hostVRange) hostProfile fromMemberKey, + memCategory = GCHostMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Nothing, + localDisplayName = hostName, + memContactId = Nothing, + memProfileId + } + GroupMember {groupMemberId} <- createNewMember_ db user groupId host badgeVerified currentTs + pure (groupMemberId, IBUnknown) + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited invitedBy incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) +``` + +Branch for an existing group (`getInvitationGroupId_`): unchanged. + +`createNewMember_` (line 2457): `GroupInfo` parameter → `GroupId`. +Calls in `createNewGroupMember` (line 2444) and `createIntroReMember` (line 2642): `(groupId' gInfo)`. + +### 4.2. `createNewMemberViaGroup` + +New function, after `createNewContactMember` (line 1379), before `createGroupRelayRecord` (line 1439). + +```haskell +createNewMemberViaGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupInfo -> GroupMember -> Connection -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewMemberViaGroup db gVar cxt user@User {userId} GroupInfo {groupId, membership} viaMember@GroupMember {memberProfile} Connection {connChatVersion, peerChatVRange} memberRole agentConnId connRequest subMode = + createWithRandomId' db gVar $ \memId -> runExceptT $ do + createdAt <- liftIO getCurrentTime + (localDisplayName, memProfileId, profile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing createdAt + let newMember = + NewGroupMember + { memInfo = MemberInfo (MemberId memId) memberRole (Just $ ChatVersionRange peerChatVRange) profile Nothing, + memCategory = GCInviteeMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUser, + memInvitedByGroupMemberId = Just $ groupMemberId' membership, + localDisplayName, + memContactId = Nothing, + memProfileId + } + member@GroupMember {groupMemberId} <- createNewMember_ db user groupId newMember badgeVerified createdAt + liftIO $ do + DB.execute + db + "UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ?" + (connRequest, groupMemberId' viaMember, groupMemberId) + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + pure member +``` + +### 4.3. `getMemberInvitedVia` + +New function, after `getMemberInvitation` (line 2237). + +```haskell +getMemberInvitedVia :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> IO (Maybe GroupMember) +getMemberInvitedVia db cxt user@User {userId} GroupInfo {groupId} viaMember = do + currentTs <- getCurrentTime + maybeFirstRow (toContactMember currentTs cxt user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?)") + (userId, groupId, groupMemberId' viaMember, GSMemRejected, GSMemRemoved, GSMemLeft) +``` + +Excluded statuses: as `contactMember` (`Commands.hs` line 4008). + +## 5. Sending + +`src/Simplex/Chat/Library/Commands.hs`. + +### 5.1. `APIAddMemberViaGroup` handler + +After `APIAddMember` (line 2814), before `APIJoinGroup` (line 2844). + +```haskell + APIAddMemberViaGroup groupId viaGroupId gmId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do + (g@(GIK gInfo _), viaGInfo, m) <- withFastStore $ \db -> + (,,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupInfo db cxt user viaGroupId <*> getGroupMember db cxt user viaGroupId gmId + let GroupMember {localDisplayName = mName} = m + when (viaGroupId == groupId) $ throwChatError $ CEGroupDuplicateMember mName + when (useRelays' gInfo) $ throwCmdError "can't invite member to channel" + when (useRelays' viaGInfo) $ throwCmdError "can't invite member from channel" + assertUserGroupRole gInfo $ max GRAdmin memRole + assertUserGroupRole viaGInfo GRAuthor + unless (groupFeatureUserAllowed SGFDirectMessages viaGInfo) $ throwCmdError "direct messages not allowed" + when (incognitoMembership viaGInfo) $ throwChatError CEContactIncognitoCantInvite + when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite + mConn <- case memberConn m of + Just conn | memberCurrent m -> pure conn + _ -> throwChatError CEGroupMemberNotActive + unless (m `supportsVersion` groupInvVersion) $ throwChatError CEPeerChatVRangeIncompatible + let sendInvitation = sendGrpInvitationViaGroup user viaGInfo m mConn g + withFastStore' (\db -> getMemberInvitedVia db cxt user gInfo m) >>= \case + Nothing -> do + gVar <- asks random + subMode <- chatReadVar subscriptionMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff True subMode + member <- withFastStore $ \db -> createNewMemberViaGroup db gVar cxt user gInfo m mConn memRole agentConnId cReq subMode + sendInvitation member cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member viaGInfo m + Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} + | memberStatus == GSMemInvited -> do + unless (mRole == memRole) $ withFastStore' $ \db -> updateGroupMemberRole db user member memRole + withFastStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member {memberRole = memRole} viaGInfo m + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo mName + | otherwise -> throwChatError $ CEGroupDuplicateMember mName +``` + +### 5.2. `sendGrpInvitationViaGroup` + +New function, after `sendGrpInvitation` (line 4301). +`sendGrpInvitation`: unchanged. +`localDisplayName` in the item: name of the member in group A. + +```haskell + sendGrpInvitationViaGroup :: User -> GroupInfo -> GroupMember -> Connection -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitationViaGroup user viaGInfo GroupMember {localDisplayName} mConn (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberKey = Just $ groupMemberKey gks, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + business = businessChat, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } + (msg, _, _) <- sendDirectMemberMessage mConn (XGrpInv groupInv) (groupId' viaGInfo) + let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveSndChatItem user (CDGroupSnd viaGInfo Nothing) msg content + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat viaGInfo Nothing) ci] +``` + +### 5.3. Parser + +After line 6172: + +```haskell + "/_add #" *> (APIAddMemberViaGroup <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <*> memberRole), +``` + +## 6. Receiving + +`src/Simplex/Chat/Library/Subscriber.hs`. + +### 6.1. Group event dispatch + +After line 1123: + +```haskell + XGrpInv gInv -> Nothing <$ xGrpInv gInfo' m'' conn' gInv msg brokerTs +``` + +### 6.2. `processGroupInvitation` (line 2700) + +| Line | After | +|---|---| +| 2706 | `forM_ activeConn $ \conn@Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do` | +| 2711 | `(g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Left ct) peerChatVRange inv customUserProfileId memberKeys` | +| 2714-2726 | local `joinGroupAsync` and its comment: moved to 6.3 | +| 2734 | `joinGroupAsync g hostId conn connRequest (Just ct) True` | +| 2738 | `joinGroupAsync g hostId conn connRequest Nothing False` | + +### 6.3. `joinGroupAsync` + +Moved from `processGroupInvitation` to the `where` block of `processAgentMessageConn`, after `processGroupInvitation`. +Comment and body: unchanged. + +```haskell + joinGroupAsync :: GroupInfoKeys -> GroupMemberId -> Connection -> ConnReqInvitation -> Maybe Contact -> Bool -> CM () + joinGroupAsync (GIK gInfo@GroupInfo {groupId, membership} gks) hostId Connection {connId, connChatVersion, peerChatVRange} connRequest hostContact_ sameLink = do +``` + +### 6.4. `xGrpInv` + +New function, before `xGrpDirectInv` (line 3885). + +```haskell + xGrpInv :: GroupInfo -> GroupMember -> Connection -> GroupInvitation -> RcvMessage -> UTCTime -> CM () + xGrpInv viaGInfo m mConn@Connection {peerChatVRange} inv msg brokerTs + | useRelays' viaGInfo = messageError "x.grp.inv: not allowed in channels" + | isJust publicGroup = messageError "x.grp.inv: can't invite to channel" + | not (groupFeatureMemberAllowed SGFDirectMessages m viaGInfo) = messageError "x.grp.inv: direct messages not allowed" + | memberBlocked m = messageWarning "x.grp.inv: member is blocked (ignoring)" + | fromRole < GRAdmin || fromRole < memRole = messageError "x.grp.inv: inviting member role" + | fromMemId == memId = messageError "x.grp.inv: duplicate member ID" + | otherwise = do + memberKeys <- atomically . C.generateKeyPair =<< asks random + let incognitoProfileId = localProfileId <$> incognitoMembershipProfile viaGInfo + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Right m) peerChatVRange inv incognitoProfileId memberKeys + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) + let GroupMember {groupMemberId} = membership + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv viaGInfo Nothing m) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + groupMsgToView cInfo ci + if isTrue (autoAcceptGroupInvitations user) + then when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync g hostId mConn connRequest Nothing False + createInvitationItem CIGISAccepted + else do + createInvitationItem CIGISPending + toView CEvtReceivedGroupInvitationViaGroup {user, groupInfo = gInfo, viaGroupInfo = viaGInfo, viaMember = m, fromMemberRole = fromRole, memberRole = memRole} + where + GroupInvitation {fromMember = MemberIdRole fromMemId fromRole, invitedMember = MemberIdRole memId memRole, connRequest, groupProfile = GroupProfile {publicGroup}} = inv +``` + +### 6.5. Profiles after joining + +The profile copies from 4.1 and 4.2 are replaced during the connection handshake, by existing code: + +| Side | Message | Handler | +|---|---|---| +| inviting client | `x.grp.acpt` with the profile of the invited member | `processMemberProfileUpdate` (line 805) | +| invited client | `x.grp.mem.info` with the profile of the inviting member | `processMemberProfileUpdate` (line 866) | + +## 7. Joining and invitation status + +`src/Simplex/Chat/Library/Commands.hs`. + +### 7.1. `APIJoinGroup` (line 2844) + +Lines 2846-2848: + +```haskell + (invitation, ct_) <- withFastStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId + (inv,) <$> forM (memberContactId fromMember) (\_ -> getContactViaMember db cxt user fromMember) +``` + +Line 2850, `Contact {activeConn} = ct` replaced: + +```haskell + peerChatVRange_ = case ct_ of + Nothing -> Right $ memberChatVRange' fromMember + Just ct@Contact {activeConn} -> maybe (Left ct) (\Connection {peerChatVRange} -> Right peerChatVRange) activeConn +``` + +| Line | Before | After | +|---|---|---| +| 2851 | `case activeConn of` | `case peerChatVRange_ of` | +| 2852 | `Just Connection {peerChatVRange} -> do` | `Right peerChatVRange -> do` | +| 2875 | `Nothing -> throwChatError $ CEContactNotActive ct` | `Left ct -> throwChatError $ CEContactNotActive ct` | + +Lines 2853-2872: unchanged. + +Line 2873, missing invitation item ignored: + +```haskell + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` \case + ChatErrorStore SEChatItemNotFoundByGroupId {} -> pure () + e -> eToView e +``` + +Invitation from a contact deleted before joining (`contact_id` set to NULL, `Store/Direct.hs` line 492): the member path is taken; master returned `SEContactNotFoundByMemberId`. +The invitation item is deleted with the contact (`Store/Direct.hs` line 483). + +### 7.2. `updateCIGroupInvitationStatus` (line 4776) + +Line 4778, item binding: `AChatItem _ _ cInfo ci@ChatItem {content, meta = meta@CIMeta {itemId}}`. + +New alternative, after line 4786. +Timer: as the direct alternative (lines 4783-4786), from the preferences of group A. + +```haskell + (GroupChat g _, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let content' = CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole + timed_ <- sndGroupCITimed False g Nothing + ci' <- withFastStore' $ \db -> updateGroupChatItem db user (groupId' g) ci {meta = meta {itemTimed = timed_}} content' False False Nothing + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup (groupId' g) Nothing, itemId) +``` + +## 8. Terminal + +### 8.1. `src/Simplex/Chat/View.hs` + +| Line | Change | +|---|---| +| after 210 | `CRSentGroupInvitationViaGroup u g _ viaG viaM -> ttyUser u ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyMember viaM <> " in " <> ttyGroup' viaG]` | +| 517 | `viewReceivedGroupInvitation g (ttyContact' c) r` | +| after 517 | `CEvtReceivedGroupInvitationViaGroup {user = u, groupInfo = g, viaGroupInfo = viaG, viaMember = m, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyMember m <> " in " <> ttyGroup' viaG) r` | +| 735 | `CISndGroupInvitation {} -> showSndItemProhibited to` removed | +| 746 | `CIRcvGroupInvitation {} \| isJust m_ -> showRcvItemProhibited from` removed | +| 1405 | `viewReceivedGroupInvitation` takes the inviter name as `StyledString` in place of `Contact` | + +Output for an invitation from a member: + +``` +#club: alice in #team invites you to join the group as member +use /j club to accept +``` + +### 8.2. `src/Simplex/Chat/Terminal/Output.hs` + +After line 200: + +```haskell + CEvtReceivedGroupInvitationViaGroup u g viaG m _ _ -> + when (groupNtf u viaG False) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewMemberName m <> "> ", "invited you to join the group") +``` + +### 8.3. `src/Simplex/Chat/Terminal/Input.hs` + +After line 84: + +```haskell + CRSentGroupInvitationViaGroup u g _ _ _ -> whenCurrUser cc u $ setActiveGroup ct g +``` + +## 9. Directory service + +Unchanged: `APIAddMember` and `CEvtReceivedGroupInvitation` are unchanged. + +## 10. Bots API + +### 10.1. Docs + +`bots/src/API/Docs/Commands.hs`, after line 121: + +```haskell + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), +``` + +`bots/src/API/Docs/Responses.hs`, after line 94: + +```haskell + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), +``` + +`bots/src/API/Docs/Events.hs`, after line 87: + +```haskell + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), +``` + +Regenerated by `tests/APIDocs.hs`: + +- `bots/api/COMMANDS.md` +- `bots/api/EVENTS.md` +- `packages/simplex-chat-client/types/typescript/src/commands.ts` +- `packages/simplex-chat-client/types/typescript/src/responses.ts` +- `packages/simplex-chat-client/types/typescript/src/events.ts` +- `packages/simplex-chat-python/src/simplex_chat/types/_commands.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_responses.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_events.py` + +### 10.2. Wrappers + +New wrapper `apiAddMemberViaGroup`, after `apiAddMember`: + +| File | After line | Function | +|---|---|---| +| `packages/simplex-chat-nodejs/src/api.ts` | 565 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole): Promise` | +| `packages/simplex-chat-python/src/simplex_chat/api.py` | 352 | `api_add_member_via_group(group_id, via_group_id, group_member_id, member_role)` | +| `packages/simplex-chat-client/typescript/src/client.ts` | 262 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole)` | + +Command: `CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})`. +Response check: type `sentGroupInvitationViaGroup`, result `member`. +JSDoc in `api.ts`: the command description from 10.1, then "Network usage: interactive." + +## 11. Apps + +Sending: unchanged. + +### 11.1. Kotlin + +`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`: + +| Line | Change | +|---|---| +| after 6817 | `@Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR()` | +| after 7017 | `is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup"` | +| after 7208 | `is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole")` | + +Handler, after line 3130: + +```kotlin + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } +``` + +Card label: + +| File | Line | Change | +|---|---|---| +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For you only` | +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For %1$s only` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 31 | parameter `groupChat: Boolean = false,` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 113 | `if (groupChat) {` / `Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor)` / `}` | +| `.../views/chat/item/ChatItemView.kt` | 723, 727 | argument `groupChat = cInfo is ChatInfo.Group` | + +### 11.2. Swift + +`apps/ios/Shared/Model/AppAPITypes.swift`: + +| Line | Change | +|---|---| +| after 1193 | `case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole)` | +| after 1274 | `case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup"` | +| after 1358 | `case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)")` | + +Handler, `apps/ios/Shared/Model/SimpleXAPI.swift`, line 2708, second pattern in the `receivedGroupInvitation` case: + +```swift + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): +``` + +Card label, `apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`, after line 41: + +```swift + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + +``` + +## 12. Protocol docs + +`docs/protocol/simplex-chat.md`, new paragraph after the `x.grp.inv` paragraph (line 233): + +``` +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. +``` + +## 13. Tests + +`tests/ChatTests/Groups.hs`, new `describe "invite member via group"`, command `/_add # # member`: + +| Test | Setup | Expected | +|---|---|---| +| `testInviteMemberViaGroup` | alice owner of #team and #club; bob and cath in #team | invitation cards in #team; `cath ` and `/a`: unchanged; they cover the `APIAddMember` parser next to the new `/_add` alternative. + +`tests/APIDocs.hs`: run to regenerate the files in 10.1. + +## 14. Version + +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. +If a release with version 22 ships before this feature, a separate constant with version 23 is added for this feature, and `currentChatVersion` is raised to 23. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index cb0e3387ed..0348469060 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -169,6 +169,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings + Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group else exposed-modules: Simplex.Chat.Archive @@ -347,6 +348,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings + Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index be3025bd25..2d553bdc9b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -465,6 +465,7 @@ data ChatCommand | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | APIGetConnNtfMessages (NonEmpty ConnMsgReq) | APIAddMember {groupId :: GroupId, contactId :: ContactId, memberRole :: GroupMemberRole} + | APIAddMemberViaGroup {groupId :: GroupId, viaGroupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIAcceptMember {groupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} | APIDeleteMemberSupportChat GroupId GroupMemberId @@ -877,6 +878,7 @@ data ChatResponse | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} + | CRSentGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, member :: GroupMember, viaGroupInfo :: GroupInfo, viaMember :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRFileTransferStatusXFTP User AChatItem | CRUserProfile {user :: User, profile :: Profile} @@ -1028,6 +1030,7 @@ data ChatEvent | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} + | CEvtReceivedGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, viaGroupInfo :: GroupInfo, viaMember :: GroupMember, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CEvtGroupLinkDataUpdated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay], relaysChanged :: Bool} | CEvtGroupRelayUpdated {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRelay :: GroupRelay} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 1c0510c31d..c37c45ec90 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2839,15 +2839,51 @@ processChatCommand cxt nm = \case pure $ CRSentGroupInvitation user gInfo contact member {memberRole = memRole} Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName + APIAddMemberViaGroup groupId viaGroupId gmId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do + (g@(GIK gInfo _), viaGInfo, m) <- withFastStore $ \db -> + (,,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupInfo db cxt user viaGroupId <*> getGroupMember db cxt user viaGroupId gmId + let GroupMember {localDisplayName = mName} = m + when (viaGroupId == groupId) $ throwChatError $ CEGroupDuplicateMember mName + when (useRelays' gInfo) $ throwCmdError "can't invite member to channel" + when (useRelays' viaGInfo) $ throwCmdError "can't invite member from channel" + assertUserGroupRole gInfo $ max GRAdmin memRole + assertUserGroupRole viaGInfo GRAuthor + unless (groupFeatureUserAllowed SGFDirectMessages viaGInfo) $ throwCmdError "direct messages not allowed" + when (incognitoMembership viaGInfo) $ throwChatError CEContactIncognitoCantInvite + when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite + mConn <- case memberConn m of + Just conn | memberCurrent m -> pure conn + _ -> throwChatError CEGroupMemberNotActive + unless (m `supportsVersion` groupInvVersion) $ throwChatError CEPeerChatVRangeIncompatible + let sendInvitation = sendGrpInvitationViaGroup user viaGInfo m mConn g + withFastStore' (\db -> getMemberInvitedVia db cxt user gInfo m) >>= \case + Nothing -> do + gVar <- asks random + subMode <- chatReadVar subscriptionMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff True subMode + member <- withFastStore $ \db -> createNewMemberViaGroup db gVar cxt user gInfo m mConn memRole agentConnId cReq subMode + sendInvitation member cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member viaGInfo m + Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} + | memberStatus == GSMemInvited -> do + unless (mRole == memRole) $ withFastStore' $ \db -> updateGroupMemberRole db user member memRole + withFastStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member {memberRole = memRole} viaGInfo m + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo mName + | otherwise -> throwChatError $ CEGroupDuplicateMember mName APIJoinGroup groupId enableNtfs -> withUser $ \user@User {userId} -> do withGroupLock "joinGroup" groupId $ do - (invitation, ct) <- withFastStore $ \db -> do + (invitation, ct_) <- withFastStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId - (inv,) <$> getContactViaMember db cxt user fromMember + (inv,) <$> forM (memberContactId fromMember) (\_ -> getContactViaMember db cxt user fromMember) let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}, groupKeys = gks} = invitation - Contact {activeConn} = ct - case activeConn of - Just Connection {peerChatVRange} -> do + peerChatVRange_ = case ct_ of + Nothing -> Right $ memberChatVRange' fromMember + Just ct@Contact {activeConn} -> maybe (Left ct) (\Connection {peerChatVRange} -> Right peerChatVRange) activeConn + case peerChatVRange_ of + Right peerChatVRange -> do subMode <- chatReadVar subscriptionMode dm <- encodeXGrpAcpt user (GIK g gks) peerChatVRange agentConnId <- case memberConn fromMember of @@ -2868,9 +2904,11 @@ processChatCommand cxt nm = \case updateGroupMemberStatus db userId fromMember GSMemInvited updateGroupMemberStatus db userId membership GSMemInvited throwError e - updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` eToView + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` \case + ChatErrorStore SEChatItemNotFoundByGroupId {} -> pure () + e -> eToView e pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing - Nothing -> throwChatError $ CEContactNotActive ct + Left ct -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do (g@(GIK gInfo _), m) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupMemberById db cxt user gmId -- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member @@ -4318,6 +4356,25 @@ processChatCommand cxt nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) + sendGrpInvitationViaGroup :: User -> GroupInfo -> GroupMember -> Connection -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitationViaGroup user viaGInfo GroupMember {localDisplayName} mConn (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberKey = Just $ groupMemberKey gks, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + business = businessChat, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } + (msg, _, _) <- sendDirectMemberMessage mConn (XGrpInv groupInv) (groupId' viaGInfo) + let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveSndChatItem user (CDGroupSnd viaGInfo Nothing) msg content + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat viaGInfo Nothing) ci] addRelays :: User -> GroupInfoKeys -> ShortLinkContact -> [UserChatRelay] -> CM [(UserChatRelay, Either ChatError GroupRelay)] addRelays user g@(GIK gInfo@GroupInfo {membership} gks) groupSLink relays = mapConcurrently addRelay relays @@ -4773,7 +4830,7 @@ processChatCommand cxt nm = \case shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId' conn) SCMInvitation userLinkData Nothing False Nothing) updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do - AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db cxt user groupId + AChatItem _ _ cInfo ci@ChatItem {content, meta = meta@CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db cxt user groupId case (cInfo, content) of (DirectChat ct@Contact {contactId}, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) | status == CIGISPending -> do @@ -4782,6 +4839,14 @@ processChatCommand cxt nm = \case updateDirectChatItemView user ct itemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, itemId) + (GroupChat g _, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let content' = CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole + timed_ <- sndGroupCITimed False g Nothing + ci' <- withFastStore' $ \db -> updateGroupChatItem db user (groupId' g) ci {meta = meta {itemTimed = timed_}} content' False False Nothing + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup (groupId' g) Nothing, itemId) _ -> pure () -- prohibited assertAllowedContent :: MsgContent -> CM () assertAllowedContent = \case @@ -6168,6 +6233,7 @@ chatCommandP = "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), "/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), + "/_add #" *> (APIAddMemberViaGroup <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI "/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a07a8c754b..a1e748831c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1121,6 +1121,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck (GIK gInfo' gks) m'' ackVer ackErr XGrpRosterRequest reqVer -> Nothing <$ xGrpRosterRequest (GIK gInfo' gks) m'' reqVer + XGrpInv gInv -> Nothing <$ xGrpInv gInfo' m'' conn' gInv msg brokerTs -- TODO [knocking] why don't we forward these messages? XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward (GIK gInfo' gks) Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs @@ -1654,7 +1655,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason xGrpRelayInv :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> GroupRelayInvitation -> CM () xGrpRelayInv invId chatVRange signedMsg_ groupRelayInv@GroupRelayInvitation {groupLink} - | maxVersion chatVRange >= signedRelayInvVersion && isNothing presHeader_ = + | maxVersion chatVRange >= groupInvVersion && isNothing presHeader_ = messageError "x.grp.relay.inv: invitation is not signed by the owner" | otherwise = do rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink @@ -2703,27 +2704,14 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | otherwise = do let Contact {localDisplayName = c, activeConn} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv - forM_ activeConn $ \Connection {connId, connChatVersion, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do + forM_ activeConn $ \conn@Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile memberKeys <- atomically . C.generateKeyPair =<< asks random - (GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} gks, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId memberKeys + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Left ct) peerChatVRange inv customUserProfileId memberKeys void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId} = membership - -- hostContact is only reported for group links, where the client replaces - -- the transient host connection view with the group and removes its chat - joinGroupAsync hostContact_ sameLink = do - subMode <- chatReadVar subscriptionMode - dm <- encodeXGrpAcpt user (GIK gInfo gks) peerChatVRange - (connIds@(cmdId, acId), _) <- prepareAgentJoin user True connRequest - withStore' $ \db -> do - when sameLink $ setViaGroupLinkUri db groupId connId - createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode - updateGroupMemberStatusById db userId hostId GSMemAccepted - updateGroupMemberStatus db userId membership GSMemAccepted - joinAgentConnectionAsync cmdId False acId True connRequest dm subMode - toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} hostContact_ createInvitationItem invStatus = do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content @@ -2731,11 +2719,11 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] if | sameGroupLinkId groupLinkId groupLinkId' -> - joinGroupAsync (Just ct) True + joinGroupAsync g hostId conn connRequest (Just ct) True | isTrue (autoAcceptGroupInvitations user) -> -- a resent invitation returns the existing group, so only join while still invited when (memberStatus membership == GSMemInvited) $ do - joinGroupAsync Nothing False + joinGroupAsync g hostId conn connRequest Nothing False createInvitationItem CIGISAccepted | otherwise -> do createInvitationItem CIGISPending @@ -2747,6 +2735,21 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag sameGroupLinkId (Just gli) (Just gli') = gli == gli' sameGroupLinkId _ _ = False + -- hostContact is only reported for group links, where the client replaces + -- the transient host connection view with the group and removes its chat + joinGroupAsync :: GroupInfoKeys -> GroupMemberId -> Connection -> ConnReqInvitation -> Maybe Contact -> Bool -> CM () + joinGroupAsync (GIK gInfo@GroupInfo {groupId, membership} gks) hostId Connection {connId, connChatVersion, peerChatVRange} connRequest hostContact_ sameLink = do + subMode <- chatReadVar subscriptionMode + dm <- encodeXGrpAcpt user (GIK gInfo gks) peerChatVRange + (connIds@(cmdId, acId), _) <- prepareAgentJoin user True connRequest + withStore' $ \db -> do + when sameLink $ setViaGroupLinkUri db groupId connId + createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode + updateGroupMemberStatusById db userId hostId GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + joinAgentConnectionAsync cmdId False acId True connRequest dm subMode + toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} hostContact_ + checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> CM () checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of MsgOk -> pure () @@ -3882,6 +3885,35 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag unless (isUserGrpFwdRelay gInfo) $ connectToRelayAsync user gInfo rl pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + xGrpInv :: GroupInfo -> GroupMember -> Connection -> GroupInvitation -> RcvMessage -> UTCTime -> CM () + xGrpInv viaGInfo m mConn@Connection {peerChatVRange} inv msg brokerTs + | useRelays' viaGInfo = messageError "x.grp.inv: not allowed in channels" + | isJust publicGroup = messageError "x.grp.inv: can't invite to channel" + | not (groupFeatureMemberAllowed SGFDirectMessages m viaGInfo) = messageError "x.grp.inv: direct messages not allowed" + | memberBlocked m = messageWarning "x.grp.inv: member is blocked (ignoring)" + | fromRole < GRAdmin || fromRole < memRole = messageError "x.grp.inv: inviting member role" + | fromMemId == memId = messageError "x.grp.inv: duplicate member ID" + | otherwise = do + memberKeys <- atomically . C.generateKeyPair =<< asks random + let incognitoProfileId = localProfileId <$> incognitoMembershipProfile viaGInfo + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Right m) peerChatVRange inv incognitoProfileId memberKeys + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) + let GroupMember {groupMemberId} = membership + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv viaGInfo Nothing m) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + groupMsgToView cInfo ci + if isTrue (autoAcceptGroupInvitations user) + then when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync g hostId mConn connRequest Nothing False + createInvitationItem CIGISAccepted + else do + createInvitationItem CIGISPending + toView CEvtReceivedGroupInvitationViaGroup {user, groupInfo = gInfo, viaGroupInfo = viaGInfo, viaMember = m, fromMemberRole = fromRole, memberRole = memRole} + where + GroupInvitation {fromMember = MemberIdRole fromMemId fromRole, invitedMember = MemberIdRole memId memRole, connRequest, groupProfile = GroupProfile {publicGroup}} = inv + xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> CM () xGrpDirectInv g@GroupInfo {groupId, groupProfile = gp} m mConn@Connection {connId = mConnId} connReq mContent_ msg brokerTs | not (groupFeatureMemberAllowed SGFDirectMessages m g) = messageError "x.grp.direct.inv: direct messages not allowed" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index aad0254b52..1266104e0e 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -87,6 +87,8 @@ import Simplex.Messaging.Version hiding (version) -- 18 - relay web capabilities (2026-05-31) -- 19 - group roster (2026-06-18) -- 20 - p2p group member keys for signing (2026-07-26) +-- 21 - command keywords with any characters (2026-09-27) +-- 22 - owner-signed relay invitations, group invitations via member connections (2026-10-10) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. @@ -143,8 +145,8 @@ groupMemberKeyVersion = VersionChat 20 anyTextCommandsVersion :: VersionChat anyTextCommandsVersion = VersionChat 21 -signedRelayInvVersion :: VersionChat -signedRelayInvVersion = VersionChat 22 +groupInvVersion :: VersionChat +groupInvVersion = VersionChat 22 data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 6f9ca5a068..e9d99fbe89 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -86,6 +86,7 @@ module Simplex.Chat.Store.Groups getContactGroupPreferences, getGroupInvitation, createNewContactMember, + createNewMemberViaGroup, createGroupRelayRecord, getGroupRelayById, getGroupRelayByGMId, @@ -140,6 +141,7 @@ module Simplex.Chat.Store.Groups createBusinessRequestGroup, getContactViaMember, getMemberInvitation, + getMemberInvitedVia, createMemberConnection, createMemberConnectionAsync, updatePreparedRelayedGroup, @@ -455,9 +457,8 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile memberId } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) -createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Either Contact GroupMember -> VersionRangeChat -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) +createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -501,8 +502,28 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs, snd memberKeys) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange (vr cxt) peerChatVRange - GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange - membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) + (groupMemberId, invitedBy) <- case inviter of + Left contact@Contact {contactId} -> do + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange + pure (groupMemberId, IBContact contactId) + Right GroupMember {memberProfile} -> do + (hostName, memProfileId, hostProfile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing currentTs + let MemberIdRole {memberId, memberRole} = fromMember + host = + NewGroupMember + { memInfo = MemberInfo memberId memberRole (Just $ ChatVersionRange hostVRange) hostProfile fromMemberKey, + memCategory = GCHostMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Nothing, + localDisplayName = hostName, + memContactId = Nothing, + memProfileId + } + GroupMember {groupMemberId} <- createNewMember_ db user groupId host badgeVerified currentTs + pure (groupMemberId, IBUnknown) + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited invitedBy incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure ( GroupInfo @@ -1436,6 +1457,32 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, ) pure indexInGroup +createNewMemberViaGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupInfo -> GroupMember -> Connection -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewMemberViaGroup db gVar cxt user@User {userId} GroupInfo {groupId, membership} viaMember@GroupMember {memberProfile} Connection {connChatVersion, peerChatVRange} memberRole agentConnId connRequest subMode = + createWithRandomId' db gVar $ \memId -> runExceptT $ do + createdAt <- liftIO getCurrentTime + (localDisplayName, memProfileId, profile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing createdAt + let newMember = + NewGroupMember + { memInfo = MemberInfo (MemberId memId) memberRole (Just $ ChatVersionRange peerChatVRange) profile Nothing, + memCategory = GCInviteeMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUser, + memInvitedByGroupMemberId = Just $ groupMemberId' membership, + localDisplayName, + memContactId = Nothing, + memProfileId + } + member@GroupMember {groupMemberId} <- createNewMember_ db user groupId newMember badgeVerified createdAt + liftIO $ do + DB.execute + db + "UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ?" + (connRequest, groupMemberId' viaMember, groupMemberId) + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + pure member + createGroupRelayRecord :: DB.Connection -> GroupInfo -> GroupMember -> UserChatRelay -> ExceptT StoreError IO GroupRelay createGroupRelayRecord db GroupInfo {groupId} GroupMember {groupMemberId} UserChatRelay {chatRelayId} = do currentTs <- liftIO getCurrentTime @@ -2239,6 +2286,15 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) +getMemberInvitedVia :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> IO (Maybe GroupMember) +getMemberInvitedVia db cxt user@User {userId} GroupInfo {groupId} viaMember = do + currentTs <- getCurrentTime + maybeFirstRow (toContactMember currentTs cxt user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?)") + (userId, groupId, groupMemberId' viaMember, GSMemRejected, GSMemRemoved, GSMemLeft) + createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionChat -> VersionRangeChat -> SubscriptionMode -> IO Connection createMemberConnection db userId GroupMember {groupMemberId} agentConnId chatV peerChatVRange subMode = do currentTs <- getCurrentTime @@ -2441,7 +2497,7 @@ createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profil memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember badgeVerified currentTs + createNewMember_ db user (groupId' gInfo) newMember badgeVerified currentTs createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> Maybe ProofPresHeader -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Profile, Maybe Bool) createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} presHeader_ createdAt = @@ -2454,11 +2510,11 @@ createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, s profileId <- insertedRowId db pure $ Right (ldn, profileId, p', badgeVerified) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupId -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} - GroupInfo {groupId} + groupId NewGroupMember { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile@Profile {badge} memKey, memCategory = memberCategory, @@ -2639,7 +2695,7 @@ createIntroReMember (localDisplayName, memProfileId, memberProfile', badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile presHeader_ currentTs let memRestriction = restriction <$> memRestrictions_ newMember = NewGroupMember {memInfo = (memInfo :: MemberInfo) {profile = memberProfile'}, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - createNewMember_ db user gInfo newMember badgeVerified currentTs + createNewMember_ db user (groupId' gInfo) newMember badgeVerified currentTs createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMemberConn diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 36f594560a..dc2de08739 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -54,6 +54,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings +import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -107,7 +108,8 @@ schemaMigrations = ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), - ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings) + ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs new file mode 100644 index 0000000000..a491dff05e --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20261010_invite_member_via_group :: Text +m20261010_invite_member_via_group = + [r| +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +|] + +down_m20261010_invite_member_via_group :: Text +down_m20261010_invite_member_via_group = + [r| +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 9c7ab5aa32..835181199c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1022,7 +1022,8 @@ CREATE TABLE test_chat_schema.group_members ( removed_at timestamp with time zone, roster_served_version bigint, member_security_code text, - member_security_code_verified_at timestamp with time zone + member_security_code_verified_at timestamp with time zone, + invited_via_group_member_id bigint ); @@ -2709,6 +2710,10 @@ CREATE INDEX idx_group_members_invited_by_group_member_id ON test_chat_schema.gr +CREATE INDEX idx_group_members_invited_via_group_member_id ON test_chat_schema.group_members USING btree (invited_via_group_member_id); + + + CREATE INDEX idx_group_members_member_profile_id ON test_chat_schema.group_members USING btree (member_profile_id); @@ -3465,6 +3470,11 @@ ALTER TABLE ONLY test_chat_schema.group_members +ALTER TABLE ONLY test_chat_schema.group_members + ADD CONSTRAINT group_members_invited_via_group_member_id_fkey FOREIGN KEY (invited_via_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE SET NULL; + + + ALTER TABLE ONLY test_chat_schema.group_members ADD CONSTRAINT group_members_member_profile_id_fkey FOREIGN KEY (member_profile_id) REFERENCES test_chat_schema.contact_profiles(contact_profile_id) ON DELETE SET NULL; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f16351f6c2..3e6d6694c6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -177,6 +177,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings +import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -353,7 +354,8 @@ schemaMigrations = ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), - ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings) + ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs new file mode 100644 index 0000000000..0212117800 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261010_invite_member_via_group :: Query +m20261010_invite_member_via_group = + [sql| +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +|] + +down_m20261010_invite_member_via_group :: Query +down_m20261010_invite_member_via_group = + [sql| +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +|] 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 535ca23fca..de3088123c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -55,6 +55,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -110,6 +111,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -322,6 +324,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -359,6 +362,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -396,6 +400,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -621,6 +626,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -657,6 +663,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -694,6 +701,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -731,6 +739,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -1304,6 +1313,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -1342,6 +1352,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -1997,6 +2008,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -2034,6 +2046,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -6235,6 +6248,29 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, 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.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, + 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, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.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, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra + 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.group_member_id = m.group_member_id + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?) +Plan: +SEARCH m USING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, 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, @@ -7006,6 +7042,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -7038,6 +7075,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -7070,6 +7108,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) @@ -7603,7 +7642,7 @@ SCAN files Query: SELECT count(1) FROM group_members Plan: -SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id +SCAN group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id Query: SELECT count(1) FROM pending_group_messages Plan: @@ -7703,6 +7742,10 @@ Query: SELECT issue_failed_since, issue_error FROM badge_purchases Plan: SCAN badge_purchases +Query: SELECT item_content FROM chat_items WHERE group_id = 1 ORDER BY chat_item_id DESC LIMIT 1 +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) + Query: SELECT last_insert_rowid() Plan: SCAN CONSTANT ROW @@ -8183,6 +8226,10 @@ Query: UPDATE group_members SET roster_served_version = ?, updated_at = ? WHERE Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members 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 95a837f5e1..d60a41047f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -260,6 +260,7 @@ CREATE TABLE group_members( roster_served_version INTEGER, member_security_code TEXT, member_security_code_verified_at TEXT, + invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -1557,6 +1558,9 @@ CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions( CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( badge_code_redemption_id ); +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members( + invited_via_group_member_id +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 3f1c434433..d6681518a9 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -82,6 +82,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c CRGroupDeletedUser u g _ _ -> whenCurrUser cc u $ unsetActiveGroup ct g CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g + CRSentGroupInvitationViaGroup u g _ _ _ -> whenCurrUser cc u $ setActiveGroup ct g CRCmdOk _ -> case cmd of Right APIDeleteUser {} -> setActive ct "" _ -> pure () diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 63a3d5cc70..7264a65bbc 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -198,6 +198,9 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case CEvtReceivedGroupInvitation u g ct _ _ -> when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") + CEvtReceivedGroupInvitationViaGroup u g viaG m _ _ -> + when (groupNtf u viaG False) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewMemberName m <> "> ", "invited you to join the group") CEvtUserJoinedGroup u g _ -> when (groupNtf u g False) $ do whenCurrUser cc u $ setActiveGroup t g sendNtf ("#" <> viewGroupName g, "you are connected to group") diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index aa509d49bf..e3e58cdf49 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -208,6 +208,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te -- CRGroupConversationsDeleted u _g _conversations -> ttyUser u [] CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c + CRSentGroupInvitationViaGroup u g _ viaG viaM -> ttyUser u ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyMember viaM <> " in " <> ttyGroup' viaG] CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci CRUserProfile u@User {profile = LocalProfile {localBadge}} p -> ttyUser u $ viewUserProfile localBadge p @@ -514,7 +515,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} in ttyUser u [sShow connId <> ": END"] CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> tshow (length conns) <> " connections on server " <> showSMPServer srv] CEvtServiceSubStatus srv event -> [plain $ serviceSubEventStr srv event] - CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r + CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyContact' c) r + CEvtReceivedGroupInvitationViaGroup {user = u, groupInfo = g, viaGroupInfo = viaG, viaMember = m, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyMember m <> " in " <> ttyGroup' viaG) r CEvtUserJoinedGroup u g m -> ttyUser u $ viewUserJoinedGroup g m CEvtGroupLinkDataUpdated u g groupLink relays relaysChanged | relaysChanged -> ttyUser u $ viewGroupLinkRelaysUpdated g groupLink relays @@ -732,7 +734,6 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa GroupChat g scopeInfo -> case chatDir of CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to context mc - CISndGroupInvitation {} -> showSndItemProhibited to _ -> showSndItem to where to = ttyToGroup g scopeInfo @@ -743,7 +744,6 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta CIRcvMsgError err -> viewRcvMsgError from err ts tz meta - CIRcvGroupInvitation {} | isJust m_ -> showRcvItemProhibited from CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False _ -> showRcvItem from @@ -1402,9 +1402,9 @@ viewConnectedToGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, m ] _ -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] -viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] -viewReceivedGroupInvitation g c role = - ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> showRole role +viewReceivedGroupInvitation :: GroupInfo -> StyledString -> GroupMemberRole -> [StyledString] +viewReceivedGroupInvitation g inviter role = + ttyFullGroup g <> ": " <> inviter <> " invites you to join the group as " <> showRole role : case incognitoMembershipProfile g of Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bf53060a67..d319d50bf1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -82,6 +82,7 @@ chatGroupTests = do it "create and delete group" testGroupDelete it "create group with the same displayName" testGroupSameName it "invitee delete group when in status invited" testGroupDeleteWhenInvited + it "invitee join group after deleting inviting contact" testGroupJoinWhenInviterDeleted it "re-add member in status invited" testGroupReAddInvited it "re-add member in status invited, change role" testGroupReAddInvitedChangeRole it "delete contact before they accept group invitation, contact joins group" testGroupDeleteInvitedContact @@ -178,6 +179,15 @@ chatGroupTests = do it "re-create member contact after deletion, many groups" testRecreateMemberContactManyGroups it "manually accept contact with group member" testMemberContactAccept it "manually accept contact with group member incognito" testMemberContactAcceptIncognito + describe "invite member via group" $ do + it "invite member of another group" testInviteMemberViaGroup + it "auto-accept invitation from member of another group" testInviteMemberViaGroupAutoAccept + it "repeat invitation before joining, after joining and after leaving" testInviteMemberViaGroupRepeat + it "join with incognito profile of another group" testInviteMemberViaGroupIncognito + it "reject invitation from member of another group" testInviteMemberViaGroupReject + it "invitation disappears after joining" testInviteMemberViaGroupDisappearing + it "prohibited without direct messages or with wrong group" testInviteMemberViaGroupProhibited + it "prohibited for member with previous version" testInviteMemberViaGroupPrevVersion describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForwardMessage it "reject forwarded content attributed to own membership" testGroupMsgForwardOwnMembershipRejected @@ -906,6 +916,33 @@ testGroupDeleteWhenInvited = bob <## "use /j team to accept" ] +testGroupJoinWhenInviterDeleted :: HasCallStack => TestParams -> IO () +testGroupJoinWhenInviterDeleted = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob" + concurrentlyN_ + [ alice <## "invitation to join the group #team sent to bob", + do + bob <## "#team: alice invites you to join the group as member" + bob <## "use /j team to accept" + ] + bob ##> "/d alice" + bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" + bob ##> "/j team" + concurrently_ + (alice <## "#team: bob joined the group") + (bob <## "#team: you joined the group") + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi" + alice <# "#team bob> hi" + testGroupReAddInvited :: HasCallStack => TestParams -> IO () testGroupReAddInvited = testChat2 aliceProfile bobProfile $ @@ -5411,6 +5448,192 @@ testMemberContactAcceptIncognito = cath ?#> ("@" <> bobIncognito <> " hey") bob ?<# (cathIncognito <> "> hey") +testInviteMemberViaGroup :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroup = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + createClub alice + threadDelay 1000000 + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + cath ("/_get chat #1 count=1", chat, [(1, "sent invitation to join group club as member")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "received invitation to join group club as member")]) + bob ##> "/j club" + concurrently_ + (alice <## "#club: bob_1 joined the group") + (bob <## "#club: you joined the group") + checkInvitationStatus bob "accepted" + alice #> "#club hello" + bob <# "#club alice_1> hello" + bob #> "#club hi" + alice <# "#club bob_1> hi" + +testInviteMemberViaGroupAutoAccept :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupAutoAccept = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + bob ##> "/set accept group invitations on" + bob <## "ok" + createClub alice + threadDelay 1000000 + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "invitation to join the group #club sent to bob in #team" + concurrently_ + (alice <## "#club: bob_1 joined the group") + (bob <## "#club: you joined the group") + bob #$> ("/_get chat #1 count=1", chat, [(0, "received invitation to join group club as member")]) + +testInviteMemberViaGroupRepeat :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupRepeat = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + addBob alice bob bobId "club" + bob ##> "/gs" + bob + <### [ "#team (2 members)", + "#club - you are invited (/j club to join, /d #club to delete invitation)" + ] + bob ##> "/j club" + concurrently_ + (alice <## "#club: bob_1 joined the group") + (bob <## "#club: you joined the group") + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "contact bob is already in the group" + bob ##> "/l club" + concurrentlyN_ + [ do + bob <## "#club: you left the group" + bob <## "use /d #club to delete the group", + alice <## "#club: bob_1 left the group (signed)" + ] + addBob alice bob bobId "club_1" + +testInviteMemberViaGroupIncognito :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c i " <> gLink) + bobIncognito <- getTermLine bob + bob <## "connection request sent incognito!" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + ] + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" (T.pack bobIncognito) + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + concurrentlyN_ + [ alice <## ("invitation to join the group #club sent to " <> bobIncognito <> " in #team"), + do + bob <## "#club: alice in #team invites you to join the group as member" + bob <## ("use /j club to join incognito as " <> bobIncognito) + ] + bob ##> "/j club" + concurrently_ + (alice <## ("#club: " <> bobIncognito <> "_1 joined the group")) + (bob <## ("#club: you joined the group incognito as " <> bobIncognito)) + +testInviteMemberViaGroupReject :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupReject = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + bob ##> "/d #club" + bob <## "#club: you deleted your local copy of the group" + checkInvitationStatus bob "rejected" + +testInviteMemberViaGroupDisappearing :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupDisappearing = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + alice ##> "/set disappear #team on 1" + alice <## "updated group preferences:" + alice <## "Disappearing messages: on (1 sec)" + bob <## "alice updated group #team: (signed)" + bob <## "updated group preferences:" + bob <## "Disappearing messages: on (1 sec)" + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + bob ##> "/j club" + concurrently_ + (alice <## "#club: bob_1 joined the group") + ( bob + <### [ "#club: you joined the group", + "timed message deleted: received invitation to join group club as member" + ] + ) + +testInviteMemberViaGroupProhibited :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupProhibited = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + alice ##> "/set direct #team off" + alice <## "updated group preferences:" + alice <## "Direct messages: off" + bob <## "alice updated group #team: (signed)" + bob <## "updated group preferences:" + bob <## "Direct messages: off" + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "bad chat command: direct messages not allowed" + alice ##> ("/_add #2 #2 " <> show bobId <> " member") + alice <## ("chat db error: SEGroupMemberNotFound {groupMemberId = " <> show bobId <> "}") + +testInviteMemberViaGroupPrevVersion :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupPrevVersion ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps testCfgVPrev "bob" bobProfile $ \bob -> do + createGroup2 "team" alice bob + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "peer chat protocol version range incompatible" + +createClub :: HasCallStack => TestCC -> IO () +createClub cc = do + cc ##> "/g club" + cc <## "group #club is created" + cc <## "to add members use /a club or /create link #club" + +addBob :: HasCallStack => TestCC -> TestCC -> GroupMemberId -> String -> IO () +addBob alice bob bobId club = do + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + concurrentlyN_ + [ alice <## "invitation to join the group #club sent to bob in #team", + do + bob <## ("#" <> club <> ": alice in #team invites you to join the group as member") + bob <## ("use /j " <> club <> " to accept") + ] + +checkInvitationStatus :: HasCallStack => TestCC -> String -> IO () +checkInvitationStatus cc status = do + [Only content] <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT item_content FROM chat_items WHERE group_id = 1 ORDER BY chat_item_id DESC LIMIT 1" :: IO [Only String] + content `shouldContain` ("\"status\":\"" <> status <> "\"") + testGroupMsgForwardMessage :: HasCallStack => TestParams -> IO () testGroupMsgForwardMessage = testChat3 aliceProfile bobProfile cathProfile $