From 1a80ecfc2951059cbc3ea89fe51ccc83b975e702 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 20 May 2025 09:07:44 +0000 Subject: [PATCH] core, ui: allow to delete member support chat; rename reject action (#5927) --- apps/ios/Shared/Model/AppAPITypes.swift | 6 +++ apps/ios/Shared/Model/SimpleXAPI.swift | 6 +++ .../ContextPendingMemberActionsView.swift | 15 +++++-- .../Views/Chat/Group/MemberSupportView.swift | 44 ++++++++++++++----- .../chat/simplex/common/model/SimpleXAPI.kt | 13 ++++++ .../ComposeContextPendingMemberActionsView.kt | 21 ++++++--- .../views/chat/group/GroupMemberInfoView.kt | 35 +++++++-------- .../views/chat/group/MemberSupportView.kt | 38 ++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 6 ++- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Library/Commands.hs | 7 +++ src/Simplex/Chat/Store/Groups.hs | 31 +++++++++++++ .../SQLite/Migrations/chat_query_plans.txt | 28 ++++++++++++ src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Groups.hs | 7 +++ 15 files changed, 212 insertions(+), 48 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index e93814ef84..2ddaf1d2af 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -69,6 +69,7 @@ enum ChatCommand: ChatCmdProtocol { case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) + case apiDeleteMemberSupportChat(groupId: Int64, groupMemberId: Int64) case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool) @@ -250,6 +251,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" + case let .apiDeleteMemberSupportChat(groupId, groupMemberId): return "/_delete member chat #\(groupId) \(groupMemberId)" case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)" case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))" case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))" @@ -425,6 +427,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" case .apiAcceptMember: return "apiAcceptMember" + case .apiDeleteMemberSupportChat: return "apiDeleteMemberSupportChat" case .apiMembersRole: return "apiMembersRole" case .apiBlockMembersForAll: return "apiBlockMembersForAll" case .apiRemoveMembers: return "apiRemoveMembers" @@ -851,6 +854,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: SimpleXChat.Group) case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) case groupUpdated(user: UserRef, toGroup: GroupInfo) @@ -900,6 +904,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .leftMemberUser: "leftMemberUser" case .groupMembers: "groupMembers" case .memberAccepted: "memberAccepted" + case .memberSupportChatDeleted: "memberSupportChatDeleted" case .membersRoleUser: "membersRoleUser" case .membersBlockedForAllUser: "membersBlockedForAllUser" case .groupUpdated: "groupUpdated" @@ -945,6 +950,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3363bf184a..8621baaade 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1646,6 +1646,12 @@ func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: Gro throw r.unexpected } +func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupInfo, GroupMember) { + let r: ChatResponse2 = try await chatSendCmd(.apiDeleteMemberSupportChat(groupId: groupId, groupMemberId: groupMemberId)) + if case let .memberSupportChatDeleted(_, groupInfo, member) = r { return (groupInfo, member) } + throw r.unexpected +} + func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (GroupInfo, [GroupMember]) { let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index 5f78581360..96915b342f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -18,13 +18,13 @@ struct ContextPendingMemberActionsView: View { var body: some View { HStack(spacing: 0) { ZStack { - Text("Remove") + Text("Reject") .foregroundColor(.red) } .frame(maxWidth: .infinity) .contentShape(Rectangle()) .onTapGesture { - showRemoveMemberAlert(groupInfo, member, dismiss: dismiss) + showRejectMemberAlert(groupInfo, member, dismiss: dismiss) } ZStack { @@ -43,6 +43,15 @@ struct ContextPendingMemberActionsView: View { } } +func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { + showAlert( + title: NSLocalizedString("Reject member?", comment: "alert title"), + buttonTitle: "Reject", + buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + cancelButton: true + ) +} + func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { showAlert( NSLocalizedString("Accept member", comment: "alert title"), @@ -75,7 +84,7 @@ func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMe do { let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember) + _ = ChatModel.shared.upsertGroupMember(gInfo, acceptedMember) ChatModel.shared.updateGroup(gInfo) dismiss?() } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index cd053cc9c8..0bcf09aabd 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -100,14 +100,14 @@ struct MemberSupportView: View { Label("Accept", systemImage: "checkmark") } .tint(theme.colors.primary) + } else { + Button { + showDeleteMemberSupportChatAlert(groupInfo, memberWithChat.wrapped) + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) } - - Button { - showRemoveMemberAlert(groupInfo, memberWithChat.wrapped) - } label: { - Label("Remove", systemImage: "trash") - } - .tint(.red) } } } @@ -248,15 +248,37 @@ struct MemberSupportView: View { } } -func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { +func showDeleteMemberSupportChatAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { showAlert( - title: NSLocalizedString("Remove member?", comment: "alert title"), - buttonTitle: "Remove", - buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + title: NSLocalizedString("Delete chat with member?", comment: "alert title"), + buttonTitle: "Delete", + buttonAction: { deleteMemberSupportChat(groupInfo, member) }, cancelButton: true ) } +func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) { + Task { + do { + let (gInfo, updatedMember) = try await apiDeleteMemberSupportChat(groupInfo.groupId, member.groupMemberId) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + ChatModel.shared.updateGroup(gInfo) + } + // TODO member row doesn't get removed from list (upsertGroupMember correctly sets supportChat to nil) - this repopulates list to fix it + await ChatModel.shared.loadGroupMembers(gInfo) + } catch let error { + logger.error("apiDeleteMemberSupportChat error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error deleting member support chat", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + #Preview { MemberSupportView( groupInfo: GroupInfo.sampleData, 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 8378b6fa3b..c3b681dbc7 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 @@ -1914,6 +1914,13 @@ object ChatController { return null } + suspend fun apiDeleteMemberSupportChat(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.ApiDeleteMemberSupportChat(groupId, groupMemberId)) + if (r is API.Result && r.res is CR.MemberSupportChatDeleted) return r.res.groupInfo to r.res.member + apiErrorAlert("apiDeleteMemberSupportChat", generalGetString(MR.strings.error_deleting_member_support_chat), r) + return null + } + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): Pair>? { val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members @@ -3345,6 +3352,7 @@ sealed class CC { class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() + class ApiDeleteMemberSupportChat(val groupId: Long, val groupMemberId: Long): CC() class ApiMembersRole(val groupId: Long, val memberIds: List, val memberRole: GroupMemberRole): CC() class ApiBlockMembersForAll(val groupId: Long, val memberIds: List, val blocked: Boolean): CC() class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean): CC() @@ -3531,6 +3539,7 @@ sealed class CC { is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" + is ApiDeleteMemberSupportChat -> "/_delete member chat #$groupId $groupMemberId" is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}" @@ -3695,6 +3704,7 @@ sealed class CC { is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiAcceptMember -> "apiAcceptMember" + is ApiDeleteMemberSupportChat -> "apiDeleteMemberSupportChat" is ApiMembersRole -> "apiMembersRole" is ApiBlockMembersForAll -> "apiBlockMembersForAll" is ApiRemoveMembers -> "apiRemoveMembers" @@ -5849,6 +5859,7 @@ sealed class 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() + @Serializable @SerialName("memberSupportChatDeleted") class MemberSupportChatDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @Serializable @SerialName("membersRoleUser") class MembersRoleUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val toRole: GroupMemberRole): CR() @@ -6027,6 +6038,7 @@ sealed class CR { is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" + is MemberSupportChatDeleted -> "memberSupportChatDeleted" is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" is MembersRoleUser -> "membersRoleUser" @@ -6198,6 +6210,7 @@ sealed class CR { 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") + is MemberSupportChatDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 401509a171..3c3f99ad94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.group.removeMember import chat.simplex.common.views.chat.group.removeMemberDialog import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -44,12 +45,12 @@ fun ComposeContextPendingMemberActionsView( .fillMaxHeight() .weight(1F) .clickable { - removeMemberDialog(rhId, groupInfo, member, chatModel, close = { ModalManager.end.closeModal() }) + rejectMemberDialog(rhId, member, chatModel, close = { ModalManager.end.closeModal() }) }, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(MR.strings.remove_pending_member_button), color = Color.Red) + Text(stringResource(MR.strings.reject_pending_member_button), color = Color.Red) } Column( @@ -69,6 +70,17 @@ fun ComposeContextPendingMemberActionsView( } } +fun rejectMemberDialog(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.reject_pending_member_alert_title), + confirmText = generalGetString(MR.strings.reject_pending_member_button), + onConfirm = { + removeMember(rhId, member, chatModel, close) + }, + destructive = true, + ) +} + fun acceptMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_pending_member_alert_title), @@ -105,12 +117,9 @@ private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, val r = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) if (r != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, r.second) + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) chatModel.chatsContext.updateGroup(rhId, r.first) } - withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, r.second) - } } close?.invoke() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 0ce0f8fa3c..e56bc36562 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -245,29 +245,28 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c text = generalGetString(messageId), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { - withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) - if (r != null) { - val (updatedGroupInfo, removedMembers) = r - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) - removedMembers.forEach { removedMember -> - chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) - } - } - withContext(Dispatchers.Main) { - removedMembers.forEach { removedMember -> - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, removedMember) - } - } - } - close?.invoke() - } + removeMember(rhId, member, chatModel, close) }, destructive = true, ) } +fun removeMember(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { + withBGApi { + val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + if (r != null) { + val (updatedGroupInfo, removedMembers) = r + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) + removedMembers.forEach { removedMember -> + chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) + } + } + } + close?.invoke() + } +} + @Composable fun GroupMemberInfoLayout( rhId: Long?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 0ef63a2a11..298a545c8c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource -import kotlinx.coroutines.launch +import kotlinx.coroutines.* @Composable fun ModalData.MemberSupportView( @@ -269,14 +269,34 @@ private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupIn acceptMemberDialog(rhId, groupInfo, member) showMenu.value = false }) + } else { + ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + deleteMemberSupportChatDialog(rhId, groupInfo, member) + showMenu.value = false + }) + } + } +} + +fun deleteMemberSupportChatDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.delete_member_support_chat_alert_title), + confirmText = generalGetString(MR.strings.delete_member_support_chat_button), + onConfirm = { + deleteMemberSupportChat(rhId, groupInfo, member) + }, + destructive = true, + ) +} + +private fun deleteMemberSupportChat(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + withBGApi { + val r = chatModel.controller.apiDeleteMemberSupportChat(rhId, groupInfo.groupId, member.groupMemberId) + if (r != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) + } } - ItemAction(stringResource(MR.strings.remove_pending_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { - removeMemberDialog(rhId, groupInfo, member, chatModel) - showMenu.value = false - }) - // TODO [knocking] mark read, mark unread - // ItemAction(stringResource(MR.strings.mark_unread), painterResource(MR.images.ic_mark_chat_unread), onClick = { - // showMenu.value = false - // }) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b130970467..7bb6562a3f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -159,6 +159,7 @@ Error adding member(s) Error joining group Error accepting member + Error deleting member support chat Cannot receive file Sender cancelled file transfer. Unknown servers! @@ -2188,10 +2189,13 @@ Chats with members No chats with members + Delete chat + Delete chat with member? Chat with admins - Remove + Reject + Reject member? Accept Accept member Member will join the group, accept member? diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 36d6b0aa4a..753e4543d6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -360,6 +360,7 @@ data ChatCommand | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIAcceptMember GroupId GroupMemberId GroupMemberRole + | APIDeleteMemberSupportChat GroupId GroupMemberId | APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole | APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool | APIRemoveMembers {groupId :: GroupId, groupMemberIds :: Set GroupMemberId, withMessages :: Bool} @@ -704,6 +705,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9c1b31efb3..79714af0ec 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2098,6 +2098,12 @@ processChatCommand' vr = \case Just c | connReady c -> GSMemConnected _ -> GSMemAnnounced _ -> throwCmdError "member should be pending approval and invitee, or pending review and not invitee" + APIDeleteMemberSupportChat groupId gmId -> withUser $ \user -> do + (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId + when (isNothing $ supportChat m) $ throwCmdError "member has no support chat" + when (memberPending m) $ throwCmdError "member is pending" + (gInfo', m') <- withFastStore' $ \db -> deleteGroupMemberSupportChat db user gInfo m + pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId . procCmd $ do g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId @@ -4106,6 +4112,7 @@ chatCommandP = "/_add #" *> (APIAddMember <$> 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), "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* " blocked=" <*> onOffP), "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)), diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 68fdb5c0be..4470641491 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -80,6 +80,7 @@ module Simplex.Chat.Store.Groups updateGroupMemberStatus, updateGroupMemberStatusById, updateGroupMemberAccepted, + deleteGroupMemberSupportChat, updateGroupMembersRequireAttention, decreaseGroupMembersRequireAttention, increaseGroupMembersRequireAttention, @@ -1231,6 +1232,36 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status (status, role, currentTs, userId, groupMemberId) pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} +deleteGroupMemberSupportChat :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) +deleteGroupMemberSupportChat db user@User {userId} g@GroupInfo {groupId, membersRequireAttention} m@GroupMember {groupMemberId, supportChat} = do + let requiredAttention = gmRequiresAttention m + currentTs <- getCurrentTime + DB.execute + db + [sql| + DELETE FROM chat_items + WHERE group_scope_group_member_id = ? + |] + (Only groupMemberId) + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = NULL, + support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0, + support_chat_last_msg_from_member_ts = NULL, + updated_at = ? + WHERE group_member_id = ? + |] + (currentTs, groupMemberId) + let m' = m {supportChat = Nothing, updatedAt = currentTs} + g' <- if requiredAttention + then decreaseGroupMembersRequireAttention db user g + else pure g + pure (g', m') + updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo updateGroupMembersRequireAttention db user g member member' | nowRequires && not didRequire = 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 44b915a8fe..cf3c3fb206 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3482,6 +3482,21 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + DELETE FROM chat_items + WHERE group_scope_group_member_id = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? @@ -4405,6 +4420,19 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_ts = NULL, + support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0, + support_chat_last_msg_from_member_ts = NULL, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_profiles SET preferences = ?, updated_at = ? diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c11df38dba..c13b164693 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -222,6 +222,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m + CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"] CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 591a8fb311..d1588ce6fb 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3118,6 +3118,10 @@ testGLinkReviewMember = (bob "/_delete member chat #1 5" + alice <## "bad chat command: member is pending" + -- accept member dan ##> "/_accept member #1 5 member" concurrentlyN_ @@ -6923,6 +6927,9 @@ testScopedSupportSingleModerator = cath ##> "/_send #1(_support:3) text 5" cath <## "#team: you have insufficient permissions for this action, the required role is moderator" + alice ##> "/_delete member chat #1 2" + alice <## "#team: bob support chat deleted" + testScopedSupportManyModerators :: HasCallStack => TestParams -> IO () testScopedSupportManyModerators = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do